@lorekit/cli
v1.78.0
Published
Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.
Maintainers
Readme
@lorekit/cli
The fastest way to give your AI coding agent a memory that survives the session.
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 greenInstall
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 doctorRequires 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:
- 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. - MCP server (
lorekit) — the connection to your lessons, merged into the MCP config (preserving any other servers). - 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 onStop— by default only when the session actually hit friction (hooks.stop). These fire the sharedlorekit hookengine and are merged intosettings.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 projectClaude 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, hooksSet 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 aloneIn 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
lorekitwhen one is on yourPATH(fast), otherwisenpx -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-memoryskill 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.jsonhas alorekitserver, 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/v1with no/mcpstill answers the REST-based probes below but 404s for an actual MCP client; the fix for an installed.mcp.json/~/.claude.jsonislorekit 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 — theauthenticationcheck — 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) installupdate 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 scriptsThe 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 --jsonIf 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 --jsonEach 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-flagsThe 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 } rowsEvery 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 }] rowsUnlike 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 quota
Your active memories against your plan's cap and how many writes are left — remote only, since the offline store has no plan and no cap.
lorekit quota
lorekit quota --json # { available, endpoint, plan, personal, org_count, total }personal is the pair the cap enforces; org-owned memories count against each
org's own cap and are shown beside it, never subtracted. A token narrowed to
personal or selected orgs sees only the orgs it reaches. Exits 1 when the remote
is denied, unconfigured, unreachable or erroring, so a script can gate a bulk
write on it.
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 scriptsThree 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[] listThis 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 findingsRules: 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 + signalThe 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 diffThis 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/repoA 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 labelsWith 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 memorypurge 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-lessongroom [--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 30policy 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,awprotect <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 .lorekitIt 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>.mdRead = 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 tierDry-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 itThe 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(andLOREKIT_MCP_URL/LOREKIT_TOKENfor remote), plus a user config file~/.lorekit/config.json. - Repo / team — a
.lorekit.jsonat the repo root (and/or the existinglorekitblock in.mcp.jsonfor 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.sessionStart.scopes": ["project::preferences"],
// extra scopes read on every session start, on top of
// the ones the working directory implies; their
// memories lead the block ahead of the ranked rest.
// Slotted before "global" for same-key precedence.
// Both layers merge (repo first), max 10; entries not
// shaped like a scope are ignored
"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 tolocal(if they selected it) oroff, neverremote. - A repo or CI job that declares
"deny": ["local"](no.lorekit/in the tree) makes local unselectable there — an envLOREKIT_MODE=localis capped, and resolution falls through toremote, oroffif 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> |
