Skip to content

Latest commit

 

History

History

cli

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@lorekit/cli

The fastest way to give your AI coding agent a memory that survives the session.

npm License: MIT

LoreKit gives coding agents a shared, persistent memory: a lesson one agent learns — a migration gotcha, a flaky-test fix, a costly wrong assumption — is stored once and recalled by every other agent, in every session, on every machine, CI included.

This is the CLI that connects your agent to it. One command scaffolds everything — the memory skill, the MCP connection, and the lifecycle hooks — and it's a zero-dependency Node binary that also runs fully offline against local markdown files if you never want to sign up for anything.

npm install -g @lorekit/cli
lorekit install     # wires up the skills, MCP server, and hooks
lorekit doctor      # verify it's all green

Install

The quickstart above uses the global install — recommended. You get one pinned version, the lorekit command stays on your PATH, and the plugin hooks (which call lorekit hook on every lifecycle event) fire instantly instead of paying an npx resolution cost each time. Upgrade later with npm install -g @lorekit/cli@latest.

Prefer not to install anything? Run it on demand with npx — same commands, always the latest version, fetched and cached per use:

npx @lorekit/cli install
npx @lorekit/cli doctor

Requires Node 18+ (for the built-in fetch). No dependencies. Works on macOS, Linux, and Windows (npm creates the lorekit shim on every platform).

Commands

lorekit install

Sets up the full memory loop — the same three parts as the Claude plugin, without needing a marketplace:

  1. Skills (lorekit-memory + lorekit-setup + lorekit-groom) — the model-invoked runtime read/write loop, the self-improvement loop authoring counterpart, and the store-grooming maintenance counterpart.
  2. MCP server (lorekit) — the connection to your lessons, merged into the MCP config (preserving any other servers).
  3. Hooks — the deterministic layer: lessons injected on every SessionStart, the few that match what you just typed on every substantive prompt (UserPromptSubmit, hooks.userPrompt), and on a tool failure (PostToolUseFailure) any lessons that look relevant to that failure ("you've hit this before") plus a nudge to record the fix, and a retrospective nudge on Stop — by default only when the session actually hit friction (hooks.stop). These fire the shared lorekit hook engine and are merged into settings.json (existing hooks preserved).

It first asks where to install:

  • project (default) — .claude/skills/, .mcp.json, .claude/settings.json. Scoped to this repo; commit it to share with your team.
  • global — ~/.claude/skills/, ~/.claude.json, ~/.claude/settings.json. Applies to every project you open. Your token lands in ~/.claude.json, so keep that file private.
lorekit install \
  --endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp \
  --token    lk_rw_your_token

lorekit install --global      # set it up for every project

Claude Code on the web (--mcp-json)

Claude Code on the web clones the repo fresh into an ephemeral container, so the only MCP config it can see is a committed, repo-root .mcp.json — a global ~/.claude.json lives on your machine and never travels there. --mcp-json writes exactly that file, in a committable form: it authenticates via a ${LOREKIT_TOKEN} reference in an mcp-remote --header rather than embedding the token, so there is no secret in the file.

lorekit install --mcp-json --yes            # web-ready project .mcp.json
lorekit install --global --mcp-json --yes   # + the machine-wide CLI, skills, hooks

Set LOREKIT_TOKEN as an environment secret in the web UI; the value is expanded before mcp-remote is spawned. --mcp-json always writes the project-root file regardless of --project / --global, and it composes with either — pair it with --global to get the local CLI, skills, and hooks in ~/.claude and the committable web config in one command. On a --project install it takes over .mcp.json with the committable form instead of the embedded-token one.

You have to commit the file, and .mcp.json is usually git-ignored (the default install embeds a token, so LoreKit's own .gitignore — and many projects — ignore it). Un-ignore it before committing (drop the .mcp.json line from .gitignore, negate it with !.mcp.json, or git add -f .mcp.json once); install --mcp-json warns when the file it wrote is still git-ignored, so a fresh web clone silently missing the config is not a mystery. Once .mcp.json is tracked, only run install --mcp-json in that repo — a plain install --project would embed a live token in the now-committed file (and the command warns before it does). See the Claude Code on the web guide.

In a TTY it prompts for the scope (and for --endpoint / --token if missing). Flags: --project / --global pick the scope non-interactively; --yes runs non-interactively (endpoint required via flag/env; scope defaults to project); --force overwrites an existing skill copy. Re-running is idempotent — the hook entries are updated in place, never duplicated, and an event that somehow ended up with several lorekit entries (the marketplace plugin wired on top of a CLI install, a merged settings.json, a hand edit) is collapsed back to exactly one, reported as N duplicate(s) removed.

That repair runs on the hook-wiring step, which a plain lorekit install skips on an already-complete install — it short-circuits to the "already installed" summary instead. So if your hooks are firing twice, run lorekit install --force, or re-state the wiring you want with lorekit install --hooks all (or --hooks read-only); both reach the hook step and collapse the duplicates for the events they wire. --hooks none also reaches the hook step, but it is a teardown, not a repair — it removes every lorekit hook instead of collapsing the copies.

Choosing the hooks

The hooks are a separate, explicit choice — they add a lorekit hook subprocess to three Claude Code lifecycle events and write into settings.json, so install asks rather than assuming. None of them writes memory: they inject context, and the write is still the model calling memory.write.

Mode Wires What you get
all SessionStart, UserPromptSubmit, PostToolUseFailure, Stop Lessons injected at session start, the ones matching each substantive prompt injected as you go, plus a nudge on a tool failure and a friction-gated one at end of turn
read-only SessionStart Lessons injected ONCE at session start; nothing nudges and nothing runs per turn
none — Skills + MCP only; memory stays model-invoked
lorekit install --hooks read-only   # inject lessons, never nudge
lorekit install --hooks none        # remove any wired hooks
lorekit install --no-hooks --yes    # don't wire new ones; leave existing alone

In a TTY the prompt preselects whatever is already wired, so re-running install never resurrects hooks you declined; a genuinely fresh install preselects all. --yes / a non-TTY takes that same preselected value without asking — all on a fresh install, otherwise whatever is already wired (none if you previously removed them), and a hand-wired set that matches no preset keeps exactly that set — no event is added or removed, though a stale hook command is still refreshed. Pass --hooks <mode> to choose explicitly. --hooks none removes hooks that are already there; --no-hooks only skips wiring new ones. lorekit doctor reports which events are wired, and in which scope.

Replacing a token. A plain re-run reuses the token already in your config. An interactive lorekit install --force instead asks what to do with it — keep, replace (paste a new one), or remove — so a revoked token can be swapped without hand-editing .mcp.json / ~/.claude.json. The stored token is only ever shown masked (lk_rw_…ijkl). Non-interactive runs (--yes, or no TTY) never prompt and keep reusing the stored token; pass --token to replace it in a script.

The hook command uses a global lorekit when one is on your PATH (fast), otherwise npx -y @lorekit/cli. Installing the CLI globally (npm i -g @lorekit/cli) is recommended so hooks fire without an npx resolution each time.

lorekit doctor

Verifies the setup and prints a status report:

  • Node runtime is 18+
  • the lorekit-memory skill is installed
  • the resolved memory mode and which source decided it, plus any active deny constraints
  • for local: the store path, entry count, and whether it is committed or gitignored
  • for remote: .mcp.json has a lorekit server, the endpoint is real (not the <project-ref> placeholder) and ends in /mcp (a warning, naming the config source and a fix, when it doesn't — e.g. .../functions/v1 with no /mcp still answers the REST-based probes below but 404s for an actual MCP client; the fix for an installed .mcp.json / ~/.claude.json is lorekit install --project|--global --force --endpoint <url>, since a plain re-install of an installed scope changes nothing), the token and its permission tier (lk_rw_* / lk_ro_* / lk_wo_*), that the endpoint is reachable, and — the authentication check — that the token is still accepted by the server
  • for off: a note that memory is disabled
  • the git-derived read/write scopes for the current directory
lorekit doctor            # config + connectivity checks
lorekit doctor --deep     # also does a write → read → delete round-trip (needs lk_rw_*)

Exit code is non-zero if any check fails, so it fits CI gates.

connectivity and authentication are different questions. connectivity probes the public /health function: it proves the network path and says nothing about your credential. authentication makes one authenticated, side-effect-free request and reports what the server said about the token itself:

Result Meaning
PASS — token accepted the token is live (read access confirmed)
PASS — no read permission accepted, but it is a write-only lk_wo_* token
FAIL — token REJECTED (HTTP 401) revoked, deleted, or never valid — every remote read and write is broken
WARN rate limited, unreachable, or an inconclusive answer — never reported as "revoked"

A revoked token is a failure, not a warning: fix it by creating a new token and running lorekit install --force, which offers to replace the stored one.

lorekit 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 (updates.notify) both point at. Fully offline: the shipped skill source travels in the same npm tarball as this CLI, so "installed vs shipped" is a filesystem version compare, never a network call.

lorekit update            # refresh every scope that already has an install
lorekit update --check    # dry run: report drift, write nothing
lorekit update --project  # only the project (.claude/skills) install
lorekit update --global   # only the global (~/.claude/skills) install

update never CREATES a fresh install — that stays install's job, so a scope with nothing installed is reported and left alone. Refreshing prunes any file the shipped skill no longer ships before re-copying, so a dropped rule file doesn't silently outlive the version that removed it.

lorekit list (alias ls)

Shows the lessons that apply to where you are — the scopes deriveScope resolves for the current directory (project::{name}, branch::…, repo::…, and global) — split into two clearly-labelled sections so you can see where each lesson lives:

  • Offline — the local two-tier store (.lorekit/ in the repo + ~/.lorekit/).
  • Remote — the hosted LoreKit store, reached over the REST API. When no token/endpoint is configured this section is a short note on how to set it up; it is never an error (the command still exits 0 and shows your offline lessons). A network/server error is likewise a per-scope warning, not a crash.

It is read-only — it never writes, deletes, or reveals archived lessons — and it independently queries both stores regardless of the resolved memory mode. A LOREKIT_DENY=remote (or local) ceiling suppresses that section, honoring the same deny-wins privacy invariant the control model enforces for agents.

lorekit list                 # both sections, grouped by scope
lorekit ls                   # same, via the alias
lorekit list --scope global  # narrow to a single scope
lorekit list --json          # structured { offline, remote } payload for scripts

--endpoint / --token override the remote connection; --store overrides the local project-tier directory.

lorekit search (alias grep)

Full-text search across the same applicable scopes and the same two stores as list, rendered in the same Offline / Remote split. A lesson matches when the query appears — case-insensitively, as a literal substring — in its key or value:

lorekit search sandbox            # both sections, only the matching lessons
lorekit grep "flaky test"         # same, via the alias
lorekit search migration --scope global
lorekit search build --json       # { query, offline, remote } for scripts

The query is matched with a plain substring check, never compiled as a regex, so a term full of metacharacters (a.*(b)) matches those characters verbatim — no injection, no surprises. It is read-only, hides archived lessons, and degrades the remote section gracefully (an unconfigured remote is a note, not an error; the command still exits 0). An empty query is a usage error; no matches prints a friendly "no lessons match" note (exit 0). --scope narrows to one scope; --endpoint / --token / --store behave as in list.

lorekit show

Inspect one lesson in full — its complete, untruncated value plus scope, key, updated date, tags, and which store(s) it lives in:

lorekit show global::prefer-guard-clauses
lorekit show repo::acme/widget::build-flags --json

If the same scope::key exists in both the offline and remote stores — possibly with different values — both are shown and any divergence is flagged. When it lives in only one store, that copy is shown and the other is noted as missing. It exits non-zero when the key is found in no readable store, so it fits scripts. --json emits the full normalized record(s) and which store each came from. Both a scope and a key are required (else a usage error).

Batch mode: several references in one call

Pass two or more references and show fetches all of them in one pass instead of one invocation per lesson:

lorekit show global::prefer-guard-clauses repo::acme/widget::build-flags --json

Each positional must itself parse as a complete <scope>::<key> reference — that is what tells this apart from the existing show <scope> <key> two-positional form, whose first positional is a bare scope with no ::. Results are reported in the same order the references were given, and --json emits { "results": [{ "scope", "key", "offline", "remote", "diverged" }, …] } instead of the single-reference top-level shape. The remote half of a batch is ONE round trip (POST /memories/read), not one request per reference; the offline half still resolves each reference through the two-tier store, so a project-tier lesson still shadows a same-key home-tier one.

Addressing a memory: <scope::key>

show, write and link all take a memory the same way, and the single-token <scope::key> form is canonical — it is exactly what list and search print and what write echoes back, so a key copy-pasted out of any output resolves. The explicit two-positional form is also accepted, and --scope / --key name each half outright:

lorekit show repo::acme/widget::build-flags        # canonical
lorekit show repo::acme/widget build-flags         # explicit positionals
lorekit show --scope repo::acme/widget --key build-flags

The single-token form is split at the last ::, and only when the left side is itself a complete valid scope — so a multi-segment scope stays whole (repo::acme/widget::build-flags is scope repo::acme/widget, key build-flags), and a bare repo::acme/widget is never mis-read as scope repo plus a bogus key. Because :: is reserved as the scope separator, a key that itself contains :: cannot be written as one token — use --key for those:

lorekit write --scope global --key "loop::aw-lessons" "body"

The scope is validated before any store is touched, so a typo is rejected by name (invalid scope foo — unrecognized scope type) instead of surfacing later as a missing value or, worse, a memory quietly filed under a scope that does not exist. See scope format for the grammar.

lorekit stats

An at-a-glance overview of how many lessons apply to the current directory — counted per scope and per store (Offline vs Remote), with per-store and grand totals, in the same Offline / Remote split as list:

lorekit stats                 # per-scope counts + totals for both stores
lorekit stats --scope global  # narrow to a single scope
lorekit stats --json          # { offline, remote } with per-scope { count } rows

Every applicable scope prints a row (a scope with zero lessons still shows 0, which is the point of an overview). An unconfigured remote degrades to a short note — never an error, always exit 0. --endpoint / --token / --store behave as in list. Remote counts reflect what the hosted memory.list returns per scope (the server's default page size); there is no cap-usage N / limit figure because the REST API exposes no total-count or cap endpoint.

lorekit scopes

A store-wide inventory of every distinct scope that holds lessons, with a lesson count per scope, in the same Offline / Remote split as the other read commands:

lorekit scopes                 # every scope in the store + a count each
lorekit scopes --scope repo::  # filter to scopes containing a substring
lorekit scopes --json          # { offline, remote } with [{ scope, count }] rows

Unlike list / search / stats / diff / tree — which are all cwd-scoped (they only look at the scopes that resolve for the current directory: project / branch / repo / global) — scopes enumerates every scope present in the store, regardless of the current directory. That's the whole point: it lets you see all the scopes you have lessons in, anywhere. Scopes are grouped by type (global → project → repo → branch), then alphabetically; each store shows a per-store total and scope count.

--scope <s> is a substring filter over the inventory (not a single-scope selector — an inventory of one scope would be pointless).

Offline enumeration is exact. It walks the local two-tier store and reads each lesson file's frontmatter scope string directly, rather than reverse- mapping the on-disk directory layout (which is lossy for project::{name}, stored by basename only) — so every scope is reconstructed verbatim. Lessons present in both tiers are counted once (project shadows home, the same merge list uses); archived lessons are excluded.

Remote enumeration is exact too. RemoteStore.listScopes() calls GET /memories/scopes, which aggregates one { scope, count } row per scope in Postgres (never a truncatable select('scope') plus a client-side dedupe), so the Remote section is a real inventory rendered through the same helpers as the Offline one. A denied, unconfigured, unreachable, or erroring remote degrades to a short, accurate note (network error / HTTP status — never a faked listing) at exit 0. --endpoint / --token / --store behave as in list.

lorekit diff

Compare the offline and remote stores for the applicable scopes and report where they diverge, grouped by scope:

lorekit diff                  # local-only / remote-only / conflicting, per scope
lorekit diff --scope global   # narrow to a single scope
lorekit diff --json           # { comparable, totals, groups[] } for scripts

Three groups: local-only (key present offline, absent remote), remote-only (absent offline, present remote), and conflicting (same scope::key in both, but the value or tags differ). A diff needs both stores readable — if the remote is unconfigured (or a store is denied), a meaningful diff is impossible, so diff prints a clear note (comparable: false in --json) and exits 0 rather than crashing. --endpoint / --token / --store behave as in list.

lorekit tree (alias resolve)

Show the scopes the hooks actually inject — project → branch → repo → global, in precedence order (most-specific first) — as a resolution hierarchy, and mark for any key present at more than one scope which scope's lesson wins and which are shadowed:

lorekit tree                  # the injected hierarchy with ✓ winning / ↳ shadowed marks
lorekit tree --scope global   # narrow to a single scope
lorekit tree --json           # per-entry { winning, shadowedBy } + a winners[] list

This mirrors the SessionStart hook's resolution exactly: it reads the scopes in readOrder (project → branch → repo → global) and keeps the first value seen per key, so a more-specific scope overrides a broader scope's same-key lesson. It answers "which lesson actually applies here, and what is being overridden?". The project:: scope is part of the injected set (project is the most-specific scope), so tree, the hooks, and every read command's scopeList now share one ordering. Each store is resolved independently, in the same Offline / Remote split.

lorekit lint

Flag low-quality lessons across the applicable scopes and both stores. Each finding names the rule it violated:

lorekit lint                  # findings grouped by scope; exits non-zero if any
lorekit lint --scope global   # narrow to a single scope
lorekit lint --json           # { total, offline, remote } structured findings

Rules: empty-value (blank/whitespace-only body), short-value (a non-empty body below a small length threshold), untrimmed-value (real content with surrounding whitespace), empty-key (blank key), volatile-key (the key carries a per-sighting identifier — a run of 6+ digits such as a GitHub comment id, or a pr<n> / issue<n> segment — so it never collides, never dedups, and freezes seen_count at 1), malformed-scope (e.g. a single : where :: is expected), and unkinded-state-record (the value parses as a JSON object/array but kind is unset — bare scalars are excluded, caught by short-value only when short; a longer bare scalar is judged by no rule — because the SessionStart digest excludes non-lesson kinds by kind, not by shape, so an un-kinded state record renders as a raw JSON blob inline in every session's digest; set --kind bus/--kind signal). lint exits non-zero (1) when any issue is found, so it is usable as a CI gate (lorekit lint || exit 1); a clean run — or one where only a store is unavailable — exits 0. The pure rule predicates live in lessons-view.mjs and are unit-tested one rule at a time.

lorekit dedupe

Find likely-duplicate lessons and group them into clusters — per store, across the applicable scopes:

lorekit dedupe                    # clusters of near-duplicate lessons per store
lorekit dedupe --threshold 0.6    # loosen the similarity cutoff (default 0.8)
lorekit dedupe --json             # { threshold, offline, remote } clusters + signal

The similarity signal is a zero-dependency heuristic — Jaccard overlap of lowercased word tokens, not a semantic/embedding measure — so it surfaces candidates for a human to review and can both miss paraphrases and group coincidental overlaps. Any pair scoring at or above --threshold links (transitively) into one cluster; only clusters of 2+ members are reported, each with a similarity range. Cross-store divergence is diff's job; dedupe looks within a store.

lorekit obligations

Check a changed-file set against the Surface-Partner Map — a declarative registry of known, path-keyed file partnerships (a mirrored module, a doc that copies a claim, a generated artifact) mined from existing CI guards — and flag any partner the map says the changed-set owes but doesn't contain:

lorekit obligations supabase/functions/_shared/audit/audit.ts        # positionals
lorekit obligations --files packages/schemas/src/shared/tool-catalog.ts --json
git diff --name-only origin/main... | lorekit obligations --strict   # from a real diff

This is a machine version of a recurring review finding: a fix to one surface leaves its partner stale because the lessons documenting the partnership are retrieved lexically (full-text search + recency) and rarely surface at edit time for the exact file just touched. Each matched entry prints its obliged partner files/actions, marks each as met (✓) or unmet (!), and cites the memory lessonKey the partnership encodes.

An obliges element is a required partner path/glob, a run:<action> advisory that is always reported but never gates --strict (some partnerships are "regenerate this," not "edit this file"), or an "any of" group satisfied by whichever of several candidates is present. {name} (or **/{name}) in a match/obliges glob binds a mirrored module's relative path (directories + stem, extension stripped), for the (rare) case where a partner's path genuinely IS a predictable function of the source's, so one entry covers every module instead of needing one per file.

The edge-mirror/edge-mirror-core entries (mcp-core ↔ the self-contained Deno edge mirrors) do NOT use that glob mechanism: an edge mirror doesn't reliably preserve mcp-core's directory structure (it may flatten or rename it), so a symmetric-path reconstruction false-positives on exactly those pairs. Instead, both entries are generated — one row per pair — from src/shared/mirror-pairs.mjs, the single-source inventory packages/mcp-core/src/edge/edge-parity.spec.ts also reads for its byte-comparison drift guard, so the spec and this command can never disagree about which files mirror which.

Cwd-independent by design: it matches the path STRINGS it is given against the map and never reads the filesystem or resolves scope from the current directory — the changed-set can come from a real git diff, a PR file list, or by hand, from anywhere.

Each entry declares a state: advisory is reported but gates nothing, gating fails --strict, retired is not reported. An entry may only be gating if it has a guard — something independent of this map that already asserts the partnership — so the two guard-less entries (perf-index, error-code-doc) stay advisory by construction, enforced in the tests. Entries also name the recurrence CLASS they instantiate rather than a bare lesson key, so obligations sharing a root cause read as one problem.

Exits 0 by default; --strict exits non-zero when a GATING entry's PATH obligation is unmet, --strict-all on ANY unmet obligation regardless of state. --json → { files, matched, unmet, unmetGating, ok, okGating }. CLI-only (native — no MCP tool, no REST route, no tool-catalog.ts entry): a path-matching lint utility is not an operation surface. Slice 1 of a larger design — wiring a PreToolUse hook to call this at edit time, and server-side retrieval changes, are named follow-ups, not built here.

lorekit invariants candidates

The compile pipeline's candidate scan — cluster → groom-merge → compile candidate → invariant. A read-only survey that reuses dedupe's Jaccard clustering over the memory store, then ranks the clusters worth compiling into a hand-written obligations-map.mjs entry:

lorekit invariants candidates
lorekit invariants candidates --min-seen-count 5 --json
lorekit invariants candidates --scope repo::owner/repo

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. Status is read from a status::<value> tag — the canonical home, per the lorekit-setup skill's self-improvement-loops.md — falling back to a legacy <!-- meta: … status=… --> body comment for lessons written before tags were the convention; the tag wins when both are present. Candidates are 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 dedupe ↔ recurrence-clusters.mjs join, so a candidate whose members already resolve to a named recurrence class is flagged as a stronger case ("this should join an existing invariant") than a merely similar one ("this might be a new class").

It deliberately does not classify a member's applicability signal — its visible **Applies when:** body line, or a legacy trigger-context meta field — into a glob/command/error-shape. That judgment is the human step the compile pipeline's "never auto-compile, never auto-gate" rule protects, so the raw string is printed, never interpreted — and it does not know about compiled_to (no such field exists yet, so an already-compiled candidate can still surface here — a known, named gap, not a silent omission).

Offline + Remote split like dedupe, with the same population cap. --json → { root, scopes, minSeenCount, offline, remote }; each candidate carries members (scope, key, seenCount, meta), score, and recurrenceClass. CLI-only (native — no MCP tool, no REST route, no tool-catalog.ts entry), matching how obligations is registered.

lorekit link (alias url)

Print a shareable dashboard deep-link URL to stdout — nothing else, so it pipes straight into your clipboard or a PR/Slack message:

lorekit link                              # link to the current repo/branch context
lorekit link | pbcopy                     # copy it straight to the clipboard
lorekit link global                       # the Explorer filtered to global scope
lorekit link repo::owner/repo prefer-guards   # open one lesson's detail sheet
lorekit link global::prefer-guards --json     # { url, surface, base, params }
lorekit url --q "flaky test" --owner personal # search + ownership filter
lorekit link global --tags "perf,ci"          # Explorer filtered to labels

With no arguments it links to the cwd's most-specific scope ("share what I'm looking at"). A single argument that is a valid scope — including a repo::… or branch::…::… scope — links to the Explorer filtered to that scope; a scope and key (two positionals, or the scope::key shorthand) links straight to that lesson's detail sheet. It sets both the lesson param (which opens the sheet) and scope — not because scope is needed to find the lesson (the sidebar reads one unfiltered recent set), but so the Explorer list behind the sheet is filtered to the lesson's own scope. Filter flags mirror the Explorer: --q (search), --owner <all|personal|org-slug>, --tags <a,b,c> (label filter, AND across labels; comma-separated or a JSON array), --range/--from/--to, --archived.

Every param is encodeURIComponent(JSON.stringify(value)) — the exact inverse of how the dashboard's useUrlState reads it back (JSON.parse, falling back to the default on failure). A raw ?scope=global would silently mean "all scopes", so the link must be JSON-encoded to open the intended view. --base <url> (or LOREKIT_APP_URL) overrides the dashboard host for self-hosted setups; the default is https://lorekit.io. Read-only and network-free — it derives scopes from git and builds a URL, never touching a store.

The read commands take a --link flag that short-circuits to print the equivalent deep link, reusing the same builder: show <scope::key> --link → the lesson link, search foo --link → /lore?q="foo" (+ scope), and list --link / tree --link → the Explorer filtered to the most-specific applicable scope (or --scope when given) — the dashboard filters one scope at a time, so the multi-scope list/tree view maps to its primary scope. (The same JSON-encoded links now back the hooks' write-confirmation and retrospective nudges.)

lorekit purge / lorekit purge-expired

The two maintenance sweeps. Both permanently delete rows, and neither can be undone.

lorekit purge --retention-days 30 --yes   # hard-delete archived lore older than 30 days
lorekit purge-expired --yes               # hard-delete every TTL-expired memory

purge removes archived memories past a retention window — archived lore is hidden from reads but recoverable with lorekit restore, so this is the step that makes it unrecoverable. purge-expired removes memories whose ttl_days window has passed. --retention-days accepts 1–365 and defaults to 30; it is validated before any request, so an out-of-range or non-integer value fails immediately rather than costing a round trip.

Remote only. They sweep server-side state and the offline store has no equivalent operation, so --local is refused with a message rather than quietly doing nothing.

Confirmation, not a dry run. There is no --dry-run, and that is not an omission: the purge RPCs return their count only after deleting, and the REST dry-run header stops before the write with nothing to preview — so "would purge N" cannot be answered honestly. Instead an interactive terminal is prompted, and --yes is required whenever there is nobody to ask (a pipe, CI, or --json). An unattended agent cannot purge by omission.

Scoped tokens are refused. A token restricted to specific scopes cannot run an account-wide sweep — there is no scope to check and no result set to narrow. The server's refusal is printed verbatim with a one-line next step; the CLI makes exactly one request and never retries, splits the sweep, or re-scopes around it. Use an unscoped lk_rw_* / lk_wo_* token for maintenance.

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

Retention automation — the CLI counterpart to the MCP groom.*/policy.*/ memory.protect tools and the REST /groom/*//policies*//protect routes. Remote only, matching purge: retention_policies has no local-store equivalent.

# Preview what a rule would catch, without changing anything
lorekit groom --scope repo::acme/app --unseen-days 90

# Archive the matches (soft-archive, recoverable via `lorekit restore`)
lorekit groom --scope repo::acme/app --unseen-days 90 --run --yes

# Save it as a policy you can re-run by name, or let it sweep nightly
lorekit policy create --scope repo::acme/app --name "stale repo lore" \
  --unseen-days 90 --mode review
lorekit policy list
lorekit policy update <id> --mode auto --enabled
lorekit policy delete <id>

# Exempt one lesson from every rule and every manual sweep
lorekit pin repo::acme/app::the-load-bearing-lesson
lorekit unpin repo::acme/app::the-load-bearing-lesson

groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes] previews by default; --run archives. Exactly one of --policy-id/--scope is required. policy <list|create|update|delete> manages the SAVED rules --policy-id runs; delete removes the rule only, never the lessons it matched.

Every condition ANDs together: the 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 —

Flag --<dim>-mode values (default)
--tags <a,b> any | all | none (any)
--kind <a,b> in | nin (in)
--host <a,b> in | nin (in)
--trigger <a,b> in | nin (in)
--source-agent <a,b> in | nin (in)
--origin-repo <a,b> in | nin (in)
--origin-branch <a,b> in | nin (in)
--origin-pr <a,b> in | nin (in)

— all comma-separated, same convention as write --tags a,b,c:

lorekit policy create --scope repo::acme/app --name "stale ci review events" \
  --kind bus --kind-mode in --tags ci::pr-review-state --tags-mode all \
  --min-age-days 30

policy update pairs every condition — age/count AND every dimension — with a --clear-<field> boolean (e.g. --clear-kind, --clear-min-age-days) that sends an explicit null rather than omitting the field: omitting a field on update leaves it unchanged, null clears it.

lorekit policy update <id> --clear-kind --host reviewer,aw

protect <scope::key> [--off] (or the shorter pin/unpin) marks a lesson as excluded from every policy and every groom --run, regardless of which rule would otherwise match it — persists until explicitly cleared.

lorekit hook

The shared hook engine behind the Claude Code / Cursor / Codex plugins. It is not run by hand — the plugins wire it into their hook config. It reads the host framework's JSON on stdin and prints that host's injection format on stdout (lessons at session start; the ones matching each substantive prompt as you go; relevant lessons plus a write-nudge on a tool failure; a retrospective nudge at end of turn), always exiting 0 so it can never block the host agent.

lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>

One engine serves all three hosts; each --adapter only reshapes input/output to that host's contract. See plugins/ for the bundles.

lorekit mcp

A local stdio MCP server. It exposes LoreKit's memory.* tools backed by the store the control model resolves, so an agent's .mcp.json can point at the CLI instead of mcp-remote <url> — giving the model discoverable, autonomous memory.* tool calls offline against the local .lorekit/ store (no network, Bash-restricted contexts included).

lorekit mcp                 # serve on stdin/stdout using the resolved mode
lorekit mcp --mode local --store .lorekit

It speaks JSON-RPC 2.0 over newline-delimited stdin/stdout (the MCP stdio transport, hand-rolled — zero dependencies) and is not run by hand: only JSON-RPC frames reach stdout. It serves whatever mode resolves — local serves the .lorekit/ files directly, remote passes calls through to the hosted endpoint, and off advertises no tools.

Tools advertised are derived from the canonical tool catalog (packages/schemas/src/shared/tool-catalog.ts) rather than declared here, so this server and the hosted one describe each operation identically. In local and remote mode that is memory.write, memory.read, memory.list, memory.search, memory.delete, memory.archive, memory.restore and memory.scopes, plus org.create, org.list, org.rename and org.delete (which always route to the hosted API). off advertises the org.* tools only.

An operation the catalog declares but this server cannot back — memory.purge, memory.purge_expired and memory.list_archived — carries a recorded reason in the catalog's surfaces.localMcpExempt field, so the gap is a stated decision rather than an omission.

Wire it into .mcp.json as an alternative to the mcp-remote <url> transport — this variant needs no endpoint or token for local mode:

{
  "mcpServers": {
    "lorekit": {
      "command": "npx",
      "args": ["-y", "@lorekit/cli", "mcp"]
    }
  }
}

Memory modes & the control model

Memory has a controllable backend. Three modes:

Mode Where lessons live Notes
off nowhere Memory is disabled — every hook event and store op is a silent no-op.
local markdown files in two tiers (see below) Local means not on the hosted website — local lessons never sync to the LoreKit dashboard. That is the point of local: private-by-default, greppable, git-native.
remote the hosted LoreKit API (REST) The shared, cross-machine backend. Reads stay silent until an endpoint + token are configured. This is the default.

Local store layout — two tiers

Local mode mirrors the two-tier model used by the aw / persistent-memory loops: a per-user home tier plus an opt-in per-repo project tier.

Tier Path Availability
home ~/.lorekit/ (override with LOREKIT_HOME) Always available — per-user, cross-repo.
project <repo>/.lorekit/ (override with LOREKIT_STORE) Opt-in: active only when the directory exists. Create it once to start persisting repo/branch lessons in the project.

Each tier is foldered by canonical scope, one markdown file per lesson, with YAML frontmatter (scope, key, tags, source_agent, trigger, created, updated, archived_at, origin_repo, origin_branch, origin_commit, origin_pr, expires_at, seen_count, kind, host — the same column set as format.mjs's FIELDS) and the lesson as the body:

~/.lorekit/            <repo>/.lorekit/     (opt-in)
├── config.json        ├── global/
├── global/            ├── repo/<owner>/<repo>/
├── repo/<o>/<r>/      └── branch/<owner>/<repo>/<branch>/
└── branch/<o>/<r>/…       └── <slug-of-key>.md

Read = two-tier merge. For each scope in the read order, entries from both tiers are unioned and the project tier wins on a key collision (closer scope wins), consistent with the remote narrow→broad merge.

Write routing by scope:

  • global → home tier.
  • repo:: / branch:: → project tier when it exists (opted-in), else the home tier.

To start persisting repo/branch lessons in the project, create <repo>/.lorekit/ (or run lorekit migrate --to project --yes). Commit it to share lessons with your team (git-native sharing); add it to .gitignore to keep them private to your checkout.

Delete / archive across tiers. Deleting or archiving a key removes the closest (project) copy first; a broader home copy, if any, remains and takes a second delete. This is deliberate — one delete never reaches through and erases your cross-repo home lesson.

Same tiering, two realizations (local ⟷ remote). The tiered, closer-wins merge-read is identical in both backends. Local expresses "universal vs team-shared" as two physical locations (home dir vs committed project dir); remote expresses the same distinction through the canonical scopes in one shared DB (global ≈ your cross-repo home, repo:: ≈ team-shared) — sharing comes from the account/token, so remote needs no second location. Different mechanism, same concept.

lorekit migrate — relocate a store, or push one to the hosted store

Moved or renamed a local store (e.g. an old .lore/)? migrate re-writes its entries into the current two-tier layout so lessons are never stranded:

lorekit migrate --from .lore              # dry-run: preview counts per scope
lorekit migrate --from .lore --yes        # apply, routing each entry by scope
lorekit migrate --from .lore --to project --yes   # force all entries into the project tier

Dry-run (preview) by default; --yes (or --apply) applies. Idempotent — a re-run is a no-op. It reads LoreKit's own on-disk format only (it does not import persistent-memory's ~/.agent-memory/<bucket>/ format).

--to remote — bring your local lessons up

Started offline and later connected a token? --to remote pushes the whole local store to the hosted one in a single command, instead of the agent re-writing lessons one at a time in remote mode:

lorekit migrate --from .lorekit --to remote         # dry-run: the plan, per scope
lorekit migrate --from .lorekit --to remote --yes   # push it

The connection and token come from the same place every other command reads them (.mcp.json / .lorekit.json / LOREKIT_MCP_URL + LOREKIT_TOKEN), so a deny: remote constraint still wins. Everything is checked before the first request rather than mid-push:

Condition Result
No usable connection Fails with the lorekit install command to run
Read-only lk_ro_* token Fails — a migration writes
Write-only lk_wo_* token Warns, then pushes without reading: its reads are denied, so the destination is not classified at all and every entry reports as add (the hosted upsert is idempotent either way)
Unrecognized token prefix Warns and proceeds, so a self-hosted or custom token still works
deny: remote Fails, naming the config source that denied it

What transfers, and what the server owns. scope, key, value, source_agent and trigger travel as the source states them (value is trimmed server-side), and the original creation date travels as created_at — so a migrated lesson keeps the recency its ranking depends on. The rest is not verbatim, and every departure is reported per entry, in the dry run as well as the apply:

Field What happens
updated Re-stamped at the write instant — there is no parameter for it
seen_count Starts at 1 for a key the hosted store has never seen; one it already holds lands at ITS count plus one (the RPC treats a write as a sighting, migration 00059). A local tally of 12 never transfers
tags Replace the hosted row's labels, so an untagged local entry clears them
origin_* Sticky — the RPC coalesces provenance, so an entry carrying none leaves whatever the hosted row already had
created Honoured only when the lesson is new to the hosted store (the RPC's update clause omits it, so one already there keeps its hosted date); an unusable or future-dated value is dropped for the write instant, and the entry is named
expires_at Converted to the remaining whole ttl_days; anything beyond the API's 365-day cap lands shortened

Archived and expired entries are skipped and reported as such. Neither can be represented: a write against an archived key inserts a second, live row beside it rather than reviving it, and any TTL would re-date an expired row into the future — so pushing them would resurrect lore you retired.

Idempotent, with one nuance: a re-run compares only what a hosted write can change, and compares it the way the server stores it — trimmed value, only the provenance the source carries, and an expiry that still honours the local intent (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 — matching them instant-for-instant could never converge). Comparing the server-owned fields directly would report a change forever and re-push the whole store on every run. A remote unchanged therefore means "the hosted lesson already says this", not "byte-identical row".

Rate limits and transient failures are handled rather than surfaced as failures. The run paces itself under the hosted 120 req/min limit (and says so the first time it has to wait), and retries a 429 on the server's own Retry-After — plus a 5xx or a dropped connection, which on a push of thousands of requests is the likeliest failure and the least worth losing an entry to. Any other 4xx is a decision, not a blip, and is never retried.

Retrying is bounded, so none of this should be read as "it never fails": an entry that exhausts its attempts is reported and the run exits non-zero, and five CONSECUTIVE failures of any kind stop the run early with a partial-progress report — an outage is not worth grinding a whole store through the retry budget for. Every one of those exits is safe to re-run. The memory cap (5,000 active memories on the free plan) is returned as a 429 too but is terminal — the run stops, reports how many entries landed, and exits non-zero, so you can archive or upgrade and re-run to resume.

Not in v1: --org <slug> org-owned writes (a migration lands as your personal lore) and the reverse remote → local direction.

The control model — two layers, deny-wins

Two config layers decide the mode:

  • User / machine — env LOREKIT_MODE, LOREKIT_HOME, LOREKIT_STORE, LOREKIT_DENY (and LOREKIT_MCP_URL / LOREKIT_TOKEN for remote), plus a user config file ~/.lorekit/config.json.
  • Repo / team — a .lorekit.json at the repo root (and/or the existing lorekit block in .mcp.json for the connection).

Both files share this schema — all fields optional:

// .lorekit.json  (repo root — safe to commit, no secrets)
// ~/.lorekit/config.json  (user/machine — personal overrides, not committed)
{
  // ── Mode & store ───────────────────────────────────────────────────────────
  "mode": "local",         // off | local | remote
  "store": ".lorekit",     // project-tier store path (relative to root, or absolute)
  "deny": ["remote"],      // forbid modes outright — deny always wins, union across layers

  // ── Connection (repo config only — no token, safe to commit) ───────────────
  "mcp.endpoint": "https://<ref>.supabase.co/functions/v1/mcp",
                           // committable MCP URL without token; token still comes
                           // from .mcp.json or LOREKIT_TOKEN env var

  // ── Write behaviour ────────────────────────────────────────────────────────
  "tags.default": ["team", "project::my-project"],
                           // tags appended to every memory.write from this repo/user
                           // both layers merged: repo tags first, then user tags

  "ttl.default": 90,
                           // days until a write that named no TTL expires
                           // repo wins over user (a scalar policy cannot merge)
                           // omit for the historical behaviour: memories are permanent

  "scope.defaults": {
    "repo::owner/name":     { "tags": ["team"] },
    "branch::owner/name::": { "tags": ["ephemeral"], "ttl_days": 14 },
    "global":               { "ttl_days": null }
  },
                           // per-scope tag and TTL defaults applied to writes whose scope
                           // starts with the key; matched by prefix (no wildcards needed)
                           // repo config only — this is a team-level write policy
                           // ttl_days: null means "permanent", overriding ttl.default

  // ── Hook behaviour ─────────────────────────────────────────────────────────
  "hooks.disabled": ["Stop"],
                           // suppress specific hook events; union across layers
                           // values: "SessionStart" | "UserPromptSubmit" | "PostToolUseFailure" | "Stop"

  "hooks.userPrompt": "on",
                           // the per-turn relevance pull (UserPromptSubmit):
                           //   "on"  (default) — on each substantive prompt, query the
                           //     store for memories matching what you typed and inject
                           //     at most 3 you have NOT already been shown this session
                           //   "off"           — keep the rest of hook mode "all", drop
                           //     just this event's output
                           // a switch, not a mode: the knobs a mode would expose (how
                           // many, how strict) are the two things that must not be
                           // turned up on a hook that fires every single turn.
                           // two install paths reach it: hook mode "all" ("read-only"
                           // and "none" never wire the event), and the Claude
                           // marketplace plugin, whose hooks.json wires it
                           // unconditionally — no mode involved, so hooks.userPrompt is
                           // the mode-independent opt-out there. (hooks.disabled:
                           // ["UserPromptSubmit"] also switches it off, one gate
                           // earlier, so it is not the only opt-out.) repo wins over user

  "hooks.stop": "friction",
                           // gate the end-of-turn retrospective nudge:
                           //   "friction" (default) — only nudge once/session when the
                           //     session hit friction (a failed tool call or a stuck
                           //     retry loop, read from the transcript); silent otherwise
                           //   "always"             — nudge once per session regardless
                           //   "off"                — never (same effect as disabling Stop)
                           // repo wins over user
                           // (friction is detectable only on Claude Code, which exposes a
                           //  transcript; on Cursor/Codex there is none, so "friction"
                           //  falls back to firing so no lesson is silently lost)

  "hooks.sessionStart": "hybrid",
                           // shape of the block injected at session start:
                           //   "hybrid" (default) — fill the character budget with the
                           //     highest-ranked memories, then add one line naming what
                           //     was left out and where it lives
                           //   "index"            — the same list, no trailing map
                           //                        (truncation is silent)
                           //   "map"              — lead with the scope map plus the
                           //                        three most salient memories
                           // repo wins over user; an unrecognised value is ignored and the
                           // next layer is tried, so a mistyped repo value falls through to
                           // the user layer before defaulting to hybrid

  "hooks.sessionStart.maxChars": 3000,
                           // character budget for that block (default 3000, ~750 tokens,
                           // ~25 index lines); bounded 200–20000, out-of-range CLAMPED not
                           // rejected — a small number means "keep it short", and honouring
                           // the floor is closer to that intent than restoring the default
                           // repo wins over user, and a declared-but-unparseable repo value
                           // still claims the decision (a typo'd project policy degrades to
                           // the default rather than silently becoming a per-machine one)
                           // memories are RANKED before the budget is spent, so what
                           // survives is the most-recurring and most-recent, not the newest

  "hooks.sessionStart.maxLessons": 100,
                           // how many memory LINES that block may hold, where
                           // maxChars bounds its characters — whichever binds
                           // first decides the block, so raising this alone does
                           // nothing unless maxChars comes up with it
                           // (default 100, bounded 3–200). Clamped, not
                           // rejected; repo wins over user with the same
                           // declared-value-owns-the-layer rule as maxChars.
                           // In practice this is a READ-DEPTH dial, not a size
                           // one: maxChars runs out around line 25, so what
                           // this really sets is the per-scope candidate fetch
                           // (100/scope by default) the ranker chooses from —
                           // 400 candidates across a four-scope hierarchy
                           // instead of the newest handful. It never exceeds
                           // 100/scope, the largest page GET /memories will
                           // return, so a ceiling above 100 fills its remaining
                           // lines from the other scopes instead. Below 25 the
                           // read does not shrink; you just see fewer lines.

  "hooks.sessionStart.loopCap": 1,
                           // how many memories one self-improvement loop (a
                           // "loop::<bucket>" tag) may contribute to that block
                           // (default 1, bounded 0–40; 0 excludes loop buckets
                           // entirely so only general memories are read). One per
                           // bucket keeps a loop's best lesson without letting its
                           // bookkeeping take a second slot from your codebase's
                           // memories, which are read by a different audience.
                           // Clamped,
                           // not rejected; repo wins over user with the same
                           // declared-value-owns-the-layer rule as maxChars

  "hooks.sessionStart.branchHint": "on",
                           // whether the block is nudged toward the current git
                           // branch's topic — on "feat/embedding-pipeline",
                           // embedding memories are lifted (the leading type/author
                           // segment is ignored). Default "on"; it only ever lifts
                           // an on-topic memory, never buries one. "off" restores
                           // the plain most-recurring / most-recent read. Repo wins
                           // over user, but — following hooks.userPrompt, not the
                           // maxChars layer-lock — a declared-but-unparseable repo
                           // value FALLS THROUGH to a valid user value rather than
                           // owning the layer

  "hooks.adapter": "claude",
                           // explicit adapter when auto-detection is ambiguous
                           // values: "claude" | "cursor" | "codex"
                           // repo wins over user

  "hooks.instructions": {
    "SessionStart":        "Focus on migration safety. Treat any lesson tagged 'migration' as high-priority.",
    "UserPromptSubmit":    "Prefer a memory that names the file you are editing.",
    "PostToolUseFailure":  "When recording a failure, always include the exact command and exit code.",
    "Stop":                null
  },
                           // per-event custom text appended to the hook output.
                           // both layers merged: repo instructions first, then user.
                           // null (or absent key) means no extra instruction for that event.
                           // values: string | null  (keys: "SessionStart" | "UserPromptSubmit" | "PostToolUseFailure" | "Stop")

  // ── Telemetry ──────────────────────────────────────────────────────────────
  "telemetry.disabled": true,
                           // team-level opt-out for orgs with a no-telemetry policy
                           // env LOREKIT_TELEMETRY=0 always wins if set

  // ── Dedupe threshold ───────────────────────────────────────────────────────
  "dedupe.threshold": 0.8  // Jaccard similarity cutoff for `lorekit dedupe`
                           // --threshold flag wins when passed explicitly
}

Precedence (a selection within what is allowed): env LOREKIT_MODE → user config mode → repo config mode → built-in default (remote).

Constraints (deny) always win. Denies are a union across every source and only ever accumulate — a user-level hard opt-out is a ceiling the repo cannot override:

  • A user who declares "deny": ["remote"] (privacy / compliance) can never be flipped to remote by any repo default or env flag — they resolve to local (if they selected it) or off, never remote.
  • A repo or CI job that declares "deny": ["local"] (no .lorekit/ in the tree) makes local unselectable there — an env LOREKIT_MODE=local is capped, and resolution falls through to remote, or off if both are denied.

off is never deniable, so it is always the terminal fallback. Run lorekit doctor to see the resolved mode, which source decided it, and any active deny constraints.

Default TTL

A memory with no TTL never expires. That is still the out-of-the-box behaviour, and it is the right default for a lesson someone deliberately curated — but it is the wrong one for the steady drip of observations a hook nudges an agent into writing at the end of every run. ttl.default and scope.defaults.<prefix>.ttl_days let a repo say how long its lore stays fresh.

Precedence, most specific first:

# Source Wins because
1 --ttl-days / --clear-ttl An explicit flag is the caller's assertion about this one memory. --clear-ttl is how you keep something forever in a repo that defaults to expiring.
2 The longest matching scope.defaults prefix with a ttl_days key The most specific scope is the one the config author meant. null there means permanent.
3 ttl.default The repo-wide (or user-wide) fallback.
4 No expiry Nothing configured.

Prefix matching is ::-delimited, so branch:: covers every branch scope while repo::owner does not cover repo::owner/name — owner/name is a single segment. Tags from scope.defaults union across every matching prefix; a TTL cannot, so exactly one entry wins.

A configured TTL that is out of range (or not a number) is ignored — the write succeeds with no expiry rather than failing. Config is ambient state that must never break an unrelated write; --ttl-days 999, by contrast, is a usage error, because you typed it. lorekit write names the source in its output:

  expires in 90 days (from config)

Two limits worth knowing. First, this is a client-side default: the hosted memory.write contract is unchanged, so omitting ttl_* there still means permanent. An agent talking straight to the MCP endpoint never sees your config file. Second, a hook cannot apply it — hooks only read lore and emit text; the write happens afterwards, in the agent's context. So the nudges instead advise the resolved number:

LoreKit: hit any friction worth remembering … Set ttl_days: 90 (this scope's
configured default) unless the lesson is durable enough to keep forever.

That is advice, not enforcement. An agent that ignores it writes a permanent memory, exactly as before.

Refresh on update is free. memory_write refreshes expires_at only when a ttl_* is supplied, so re-writing the same scope+key with the default applied slides the window forward — a lesson that keeps recurring keeps living, and one nobody has seen in 90 days decays. Expired rows are swept nightly, which also returns their headroom against the plan's memory cap.

Options

Flag Meaning
-d, --dir <path> Target project root (default: cwd)
--project Install into this repo: .claude/skills + .mcp.json (install; default). Narrows to the project scope (update)
--global Install for every project: ~/.claude/skills + ~/.claude.json (install). Narrows to the global scope (update)
-e, --endpoint <url> LoreKit MCP endpoint
-t, --token <token> LoreKit token
--mode <mode> Memory mode override for doctor: off / local / remote
--store <path> Local project-tier store directory (default .lorekit)
--from <path> Source store to migrate from (migrate)
--to <dest> Migration destination: home / project / remote (migrate; default routes by scope across the local tiers)
--apply Apply the migration — alias of --yes (migrate)
-y, --yes Non-interactive / apply; never prompt
--hooks <mode> Lifecycle hooks to wire: all / read-only / none (install; none removes any already wired)
--no-hooks Skip wiring the lifecycle hooks; skills + MCP only. Leaves already-wired hooks alone (install)
--mcp-json Also write a committable project .mcp.json (auth via ${LOREKIT_TOKEN}, no embedded token) for Claude Code on the web (install)
--force Overwrite existing skill files (install)
--deep Write/read/delete round-trip (doctor)
--check Dry run: report skill-install drift without writing anything (update)
--json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / obligations / invariants candidates / link / purge / purge-expired)
--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
--key <key> Name the key outright (show / write / link) — the way to address a key that itself contains ::
--link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
--base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
--threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
--files <path>... Changed files to check (obligations); also accepted as positionals or newline-separated stdin
--strict Exit non-zero on an unmet obligation from a gating entry (obligations)
--strict-all Exit non-zero on ANY unmet obligation, advisory entries included (obligations)
--min-seen-count <n> Minimum summed seen_count for a cluster to be a candidate (invariants candidates; default 3)
--retention-days <1..365> Only purge archived memories older than this (purge; default 30, derived from the tool catalog)
--adapter <name> Host framework for hook: claude / cursor / codex
--event <name> Host hook event for hook (else read from the stdin payload)
-h, --help Help
-v, --version Version

Environment variables

Variable Purpose
LOREKIT_MODE select a mode: off / local / remote
LOREKIT_DENY comma-separated modes to forbid (deny-wins); e.g. remote
LOREKIT_HOME home-tier root + config directory (default ~/.lorekit)
LOREKIT_STORE project-tier store directory (default .lorekit)
LOREKIT_MCP_URL / LOREKIT_ENDPOINT endpoint fallback
LOREKIT_TOKEN token fallback
NO_COLOR disable colored output
LOREKIT_TELEMETRY set to 0 / off / false to disable usage telemetry
DO_NOT_TRACK 1 also disables usage telemetry (cross-vendor standard)
LOREKIT_TELEMETRY_TOKEN bearer token for telemetry export (overrides the baked-in default)
OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS override the telemetry OTLP endpoint / headers

What the skills do

install scaffolds three skills:

The lorekit-memory skill teaches an agent to:

  • Read scoped lessons at the start of a task, on first navigation into unfamiliar code, and before risky operations (narrow-to-broad scope merge).
  • Write a lesson when something goes wrong — a stuck loop, a repeated failure, a gotcha, a near-miss, or a costly wrong assumption — phrased as an observation and scoped to the narrowest namespace that fits.

This mirrors the read-on-start / write-on-failure loop of the aw autonomous-workflow agent. See the skill's own SKILL.md for the full protocol.

The lorekit-setup skill is the authoring counterpart: it teaches an agent to wire a self-improvement loop into one of your own skills or workflows — the two-tier model, the lesson bucket convention, and the entrenchment guards. See its SKILL.md and rules/self-improvement-loops.md.

The lorekit-groom skill is the maintenance counterpart: it teaches an agent to run a grooming pass over an accumulated store — survey (stats / scopes), lint, dedupe & merge near-duplicates, set expiry (TTL) on time-bound lessons, and prune or archive obsolete ones. It always analyses read-only and proposes a plan before mutating (archive is preferred over hard-delete), because the store is shared and a merge or delete is permanent for every agent. See its SKILL.md, rules/grooming-pass.md, and references/merge-and-expiry.md.

A fourth skill, lorekit-self-host, ships in the skill/* directory and in the Claude plugin, but is not scaffolded by install — it's a one-time, slash-only deployment runbook (/lorekit-self-host), not everyday agent memory, so it is deliberately absent from the SKILLS list install/doctor iterate. It walks deploying your own LoreKit backend (full self-host, or self-host plus BYOD) as a series of gates: do the step, verify it, stop and diagnose on failure. See its SKILL.md.

The skills are model-invoked (the agent chooses to use them). For a deterministic guarantee — lessons injected on every session start, a nudge on every tool failure — use the framework plugins in plugins/, which fire the lorekit hook engine on host lifecycle events. The skills and the hooks compose: hooks guarantee the timing, the skills supply the authoring judgment.

Testing & validating across frameworks

npm test (or node --test test/*.test.mjs) runs four layers, so you can validate all three integrations without launching each agent by hand:

  1. Unit — scope parsing, failure heuristic, lesson formatting, adapter mapping/emit.
  2. Engine end-to-end — spawns the real lorekit hook binary for every adapter/event, including a mock MCP server that proves the SessionStart read path injects lessons, plus throttling and bad-input handling.
  3. Cross-framework conformance — replays payload fixtures through the binary and asserts the stdout matches each host's documented contract (hookSpecificOutput.additionalContext for Claude/Codex, followup_message for Cursor).
  4. Wiring — runs claude plugin validate on the Claude bundle (skipped if the claude CLI is absent) and structurally validates the Cursor and Codex configs; also asserts the vendored skill is in sync with its source.

Harvesting real fixtures (one run per framework)

Layer 3 ships with documented seed fixtures under test/fixtures/. To prove conformance against what each framework actually sends, record real payloads once by pointing its hook command at the recorder:

# Temporarily set this env for the hook command in the framework's config,
# then drive the agent through a session start, a failing command, and a stop:
LOREKIT_HOOK_RECORD=/abs/path/to/packages/cli/test/fixtures \
  npx @lorekit/cli hook --adapter claude --event SessionStart

Each invocation overwrites test/fixtures/<adapter>-<event>.json with the real payload. Commit the updated fixtures; the conformance tests then run offline forever. This reduces manual validation to a single capture pass per tool.

The one thing no offline test can cover is a real model loop (the agent actually consuming the injected context). claude plugin validate confirms the real Claude CLI accepts the wiring; for a true live check, install the plugin and start one session per tool.

Usage telemetry

The human-facing commands (install, uninstall, doctor, list, search, show, stats, scopes, diff, migrate) emit one OpenTelemetry span + one counter point per run so the maintainers can see which commands people use. It is zero-dependency (OTLP/JSON over fetch, no SDK) and deliberately narrow — it carries only the command name, a bounded set of boolean flags (--global, --deep, …), the CLI/runtime/OS identity, and the outcome. No path, token, endpoint, repo, or scope is ever sent. The hook and mcp commands are never instrumented.

Opt out any time with LOREKIT_TELEMETRY=0 (or off / false / no) or the cross-vendor DO_NOT_TRACK=1. Point it at your own collector with OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS, or set just the bearer via LOREKIT_TELEMETRY_TOKEN. Export is a no-op when no token is configured. The published package's default token is injected from a CI secret at release time and is never committed to git. See docs/otel.md for the attribute list and setup.

Security note

install writes your token into .mcp.json. Keep that file out of version control (LoreKit's root .gitignore already ignores .mcp.json).

The one exception is install --mcp-json: that file authenticates via a ${LOREKIT_TOKEN} reference instead of an embedded token, so it holds no secret and is meant to be committed (that is how Claude Code on the web reads it after a fresh clone). You'll need to un-ignore .mcp.json first, since it is normally git-ignored for the embedded-token reason above. The token itself comes from the LOREKIT_TOKEN environment variable at runtime — set it as an environment secret, never commit it — and once the file is tracked, keep using --mcp-json so a later plain install never writes a token into it.