The full role/behaviour description for the @lorekit/cli package (packages/cli/), extracted from
the root CLAUDE.md Package map. The CLAUDE.md row keeps a terse command list and
links here.
Zero-dep Node CLI. One section per command below, in registry order. Kept as separate sections rather than the single paragraph this file used to be: two edits to two unrelated commands landed on the same physical line, so every concurrent change to this file conflicted regardless of what it touched.
Scaffolds the lorekit-memory + lorekit-setup + lorekit-groom skills, MCP server, and the
deterministic lifecycle hooks into .claude/~/.claude per a project/global prompt — the plugin's
three parts without a marketplace. The hooks are an explicit choice, not a silent side effect: a
second prompt offers all (SessionStart, UserPromptSubmit, PostToolUseFailure, Stop) /
read-only (SessionStart only) / none (HOOK_MODES/hookEventsForMode/hookModeFromEvents in
config.mjs), preselected to the DETECTED state via installedHookEvents — the same reader
doctor uses — so a fresh install preselects all while a re-run never resurrects hooks the user
declined. --hooks <mode> selects non-interactively and bypasses the already-installed
short-circuit; --hooks none REMOVES wired hooks whereas the older --no-hooks stays skip-only.
upsertClaudeHooks takes the event list and PRUNES lorekit entries for events outside it, so all → read-only is a real downgrade. It is also CONVERGENT — an event carrying SEVERAL lorekit entries
keeps exactly ONE (the first updated in place, every further one deleted and reported as deduped),
because reconciling only the first left a duplicated settings.json firing the hook twice forever
and made install --force unable to repair the state it is most often run against.
LOREKIT_HOOK_RE decides what counts as ours, so it tolerates a leading path/quote, a pinned
@lorekit/cli@x.y.z and a .cmd/.exe suffix (a form it MISSES is appended alongside, which is
how the duplicates were born) while requiring a leading boundary so an unrelated mylorekit hook …
is never claimed. install returns the { exitCode, ... } shape carrying bounded
lorekit.cli.hooks_mode + lorekit.cli.completions. A third opt-in wires shell completion:
--completions auto|zsh|fish|none — an interactive run PROMPTS for the detected $SHELL (yes/no),
a non-interactive one SKIPS unless the flag is passed, because writing to a shell rc unprompted is
too invasive for a plain --yes (deliberately unlike --hooks, which defaults to all). auto
detects the shell from $SHELL; fish drops an auto-loaded
~/.config/fish/completions/lorekit.fish, zsh writes ~/.lorekit/completions/_lorekit and appends
an idempotent, guard-marked block to ~/.zshrc (zsh has no universal auto-load dir); uninstall
tears both down surgically, touching only lorekit's own file and guarded block. An INTERACTIVE
--force prompts keep/replace/remove for an already-configured token instead of silently reusing it
— the pure tokenPlan decides, and a non-interactive run still reuses
Connectivity/token/scope health checks; connectivity probes the PUBLIC /health function and
therefore proves only the network path, so a separate authentication check calls
RemoteStore.verifyAuth() — one authenticated GET /memories?limit=1 — and FAILS on a 401
revoked/deleted token, PASSES a 403 as accepted-but-unpermitted [a healthy lk_wo_*], and never
turns a 429/5xx/network error into a revoked verdict. In remote mode the endpoint check also
judges the resolved URL's shape: doctor WARNS when the endpoint does not end in /mcp (on a
*.supabase.co host, specifically /functions/v1/mcp) — never fails, since a custom reverse proxy
can legitimately serve MCP off a non-standard path. This is the gap where .../functions/v1 passes
every other check via the suffix-agnostic REST probes and then 404s for an actual MCP client. The
warning names the config source that produced the endpoint (--endpoint flag, .mcp.json,
~/.claude.json, .lorekit.json's mcp.endpoint, or an env var) and a fix command for that
source — for .mcp.json / ~/.claude.json that is lorekit install --project|--global --force --endpoint <url>; the --force is load-bearing, because install on an already-installed scope
otherwise prints "already installed" and rewrites nothing. Every installed skill also reports its
version against the one this running CLI ships (checkSkillVersions in
shared/skill-versions.mjs — an offline compare of metadata.version in each SKILL.md's
frontmatter, checked in BOTH the project and global install locations): a current install is a
pass naming its version, an outdated or unknown (legacy, predates version stamping) install is
a warn — never a fail, the skill still works — naming the installed and shipped versions and
pointing at lorekit update --<scope>, the same scope-aware wording the hooks-upgrade line above it
uses. When LOREKIT_STORAGE_URL is set, a byod storage check probes that project with
LOREKIT_STORAGE_ANON_KEY or, failing that, LOREKIT_STORAGE_SERVICE_KEY (probeByodStorage in shared/byod.mjs — one PostgREST
GET /rest/v1/memories?limit=1, plain fetch): it passes when the memories table answers and
FAILS when the project is unreachable, rejects the key, or has no LoreKit schema yet.
Offline refresh of the bundled skills (and their hook command string) to the version shipped with
the running CLI — the fix doctor's outdated-skill warning and the SessionStart drift nudge (below)
both point at. Fully offline: shipped skill source travels in the same npm tarball as the running
CLI, so "installed vs shipped" is a filesystem compare through the same shared/skill-versions.mjs
module all three surfaces share — no network probe, no npm-registry lookup. Re-copies (force) every
skill into whichever scope(s) already have an install, reusing copyDir (the same skill-copy path
install uses) and refreshing that scope's hook command string via upsertClaudeHooks (the same
call install --force makes, so a stale pinned runner or npx path is repaired too). --project /
--global narrow to one scope; with neither, every scope holding at least one existing skill install
is refreshed. update never CREATES a fresh install — that stays install's job, so a scope with
nothing installed is left alone and reported as such. --check is a dry run: reports drift (current
vs shipped version per skill), previews the file count a real run would prune, writes nothing, and
exits non-zero when it finds drift (0 once every skill is current), so it can gate a CI step or
pre-commit hook. Drift detection is deliberately version-based — the hand-authored
metadata.version in each shipped SKILL.md, not a content hash and not the CLI package version —
so a maintainer decides whether a change is worth nudging every installed agent about. That stamp
must be bumped by hand on any content change to a shipped skill (packages/cli/skill/*); a repo CI
job (scripts/ci/skill-version-guard.mjs) enforces it, failing a PR that edits a skill's files
without moving its version. See
Key decisions.
Alias ls — read-only human view of the lessons for the cwd's scopes, project/branch/repo/global,
split into an Offline local-store section and a Remote hosted section that degrades
gracefully when unconfigured; --json/--scope; the eight dimension filters policy/groom
take — --tags a,b combined by --tags-mode any|all|none [default any, the hosted
GET /memories default], and --kind/--host/--trigger/--source-agent/--origin-repo/
--origin-branch/--origin-pr a,b, each with --<dim>-mode in|nin [default in]; nin follows
the hosted lorekit_match_text/lorekit_match_int rule (migration 00066): the value must be SET
and none of the list, so a row without that field matches neither mode; --origin-pr takes PR
numbers only (#482 accepted, 007 reads as 7), because the hosted route DROPS a non-numeric
value and then applies no PR filter. All are parsed by parseReadFilters (src/shared/flags.mjs,
reusing the policy/groom DIMENSIONS table), forwarded to the remote, AND applied to both
stores by the one entryFilter predicate in lessons-view.mjs over normalizeEntry's output,
which carries source_agent/trigger/origin_* for that reason (so --json shows them too; the
pure matchesTags lives in lessons-pure.mjs because the local store applies it too); a bare
value flag, or a mode without its value flag, is a usage error rather than an unfiltered listing;
--all drains all pages via gatherStream
[default is single-page gather()]; --max <n> hard-caps entries surveyed [default 5000];
--since/--until <iso-date> forward created_since/created_until to the REST request;
independently builds BOTH stores via src/shared/stores.mjs + renders via the reusable
src/shared/lessons-view.mjs, the seam all the search/show/stats/diff read commands stack
on; the shared deny-wins block those five read commands all use is extracted into
resolveDenies(root,{env}) in control.mjs
Alias grep — full-text search the same scopes/stores/split as list; matches a literal,
case-insensitive substring against a lesson's key OR value, via the pure matchesQuery (in the
dependency-free lessons-pure.mjs, re-exported by lessons-view.mjs)/filterGroups helpers over
the shared gather() output — NOT each store's native search method, whose semantics diverge
(local literal-substring vs remote server-FTS); regex metachars are matched verbatim, never
compiled; --json/--scope; the same eight dimension filters as list (--tags +
--tags-mode any|all|none; --kind/--host/--trigger/--source-agent/--origin-* +
--<dim>-mode in|nin) narrow the candidates before the query is matched
Inspect ONE lesson in full via each store's real read({scope,key}) — complete untruncated value,
scope, key, updated, tags, and which store(s) hold it; when the same scope::key is in both stores
it shows both and flags any divergence via the pure recordsDiverge; exits non-zero when found in
no readable store; --json; the scope is OPTIONAL — show <key> (a lone positional that is not
itself a scope and does not even LOOK like an attempt at one, per looksLikeScopeAttempt, so show foo bar and show global:: still report a bad scope rather than silently hunting for a key)
resolves the key across EVERY scope the store holds, picking a winner by the shared
src/shared/scope-precedence.mjs — scope-type specificity project→branch→repo→global, then
most-recently-updated, then scope ascending. That module is the CLI twin of
packages/mcp-core/src/scope/scope-precedence.ts, held to it BEHAVIOURALLY by
scope-precedence-parity.spec.ts, so lorekit show <key> and memory.read { key } can never
resolve the same key to different lessons. Offline, LocalStore._findAllByKey walks the store and
TwoTierStore collapses each scope to one candidate (project shadowing home) before picking;
remote, RemoteStore.read widens its GET /memories page to 50 candidates and picks client-side
with the same function. --link still needs an explicit scope, a deep link naming one. variadic
— show <s1::k1> <s2::k2> … [two or more positionals that EACH parse as a complete <scope>::<key>
reference, gated by isMultiRefForm so the existing show <scope> <key> two-positional form —
whose first positional has no :: — can never trip it] batches through each store's new readMany
[one round-trip to the remote store via POST /memories/read, not one request per ref; the local
two-tier store still resolves EACH ref with project-over-home precedence], reporting every ref in
REQUEST order; --json then emits { results: [{scope,key,offline,remote,diverged}, …] } instead
of the single-ref top-level shape
At-a-glance counts of the applicable lessons per scope and per store, with per-store + grand
totals, in the same Offline/Remote split; remote counts come from GET /memories/scopes via
RemoteStore.listScopes() — the same Postgres aggregate scopes uses — NOT from row-draining
memory.list, so the count is exact for any scope size; Offline tallies via pure tallyGroups over
gather(); --json/--scope
A store-wide inventory — UNLIKE every other read command, NOT cwd-scoped: enumerates EVERY
distinct scope present in the store with a per-scope non-archived count, so a user sees all scopes
they have lessons in anywhere; pure
scopeTypeOf/sortScopeInventory/filterScopeInventory/summarizeScopeInventory in
lessons-view.mjs, sorted by scope type then name; --json/--scope [a substring filter over
the inventory, not a single-scope selector]. Offline enumeration is exact — a new
LocalStore._walkEntries()/listScopes() (+ TwoTierStore.listScopes() deduping by scope::key
across tiers, project shadowing home) recursively walks the store and reads each file's frontmatter
scope string, NEVER reverse-mapping the on-disk layout [which is lossy for project::{name},
stored by basename only], so scopes are reconstructed verbatim. Remote enumeration is exact too
— RemoteStore.listScopes() calls GET /memories/scopes, which aggregates one {scope,count} row
per scope in Postgres [lorekit_memory_scopes, migration 00039], so the Remote section renders a
REAL inventory through the same filterScopeInventory/summarizeScopeInventory helpers as Offline;
a denied/unconfigured/unreachable/erroring remote degrades to a short, accurate note via the shared
describeError at exit 0 [the old REMOTE_SCOPES_UNSUPPORTED sentinel is gone — the surface
supports enumeration now]
Remote-only: your active memories against your plan's cap and how many writes are left, from
GET /memories/quota (the same answer as the memory.quota MCP tool). personal is the pair the
cap trigger enforces — personal memories against the personal limit, remaining = limit − count,
never negative; org-owned memories count against each org's own cap, so they are shown beside it
(org_count) and never subtracted; a token narrowed to personal or selected orgs sees org_count and
total over only the orgs it reaches. The shaping is the pure memoryQuotaFromRow
(packages/mcp-core/src/limits/memory-quota.ts, mirrored to _shared/limits/). Warns at the cap
and under 10 % headroom. Unlike the other read commands it exits 1 when the remote is
denied/unconfigured/unreachable/erroring, because there is no offline answer to fall back to and a
script gating a bulk write needs to see the failure; --json emits
{ available, endpoint, plan, personal, org_count, total } or { available: false, reason }
Compare offline vs remote for the applicable scopes and report local-only / remote-only /
conflicting keys grouped by scope, via the pure diffGroups set-diff reusing recordsDiverge;
needs BOTH stores readable — an unconfigured/denied store yields a clear comparable:false note at
exit 0, never a crash; --json/--scope
Alias resolve — the scope precedence hierarchy: resolves the hook-injected readOrder scopes
[project→branch→repo→global, most-specific first] and marks per key which scope's lesson wins
and which are shadowed, mirroring core/lessons.mjs's first-seen-wins merge EXACTLY via the
SAME shared pure resolvePrecedence [now in the dependency-free lessons-pure.mjs, re-exported by
lessons-view.mjs]; per-store; --json/--scope
Flag low-quality lessons across the applicable scopes/stores — pure
LINT_RULES/lintEntry/lintGroups:
empty-value/short-value/untrimmed-value/empty-key/volatile-key [a per-sighting
identifier in the key — a 6+ digit run or a segment-anchored pr<n>/issue<n> — which never
collides, so the upsert never dedups and seen_count stays frozen at 1; conservative by design so
oauth2/sha256/wcag22/semver never fire, with an embedder-only { volatileKeyAllow } opts
hatch mirroring short-value's minValueLen, no config key]/malformed-scope [via the canonical
scopeIssue validator]/unkinded-state-record [a value that PARSES as a JSON object/array with
kind left unset — excludes bare scalars, which short-value catches only below its length
threshold (a longer bare scalar is judged by no rule) — because fetchLessons's isGeneralLesson
filter excludes non-lesson kinds by KIND, not by shape, so an un-kinded record renders as a raw JSON
blob in every SessionStart digest]/hidden-metadata [a body carrying an HTML comment, a leading
front-matter block, or a key=value header before the # title — the repudiated legacy
<!-- meta: seen_count=… status=… trigger-context=… --> convention the lorekit-setup skill once
prescribed and now forbids; every buried fact has a first-class home (the seen_count column, a
status:: tag, the trigger field), and the lorekit-groom skill folds-and-strips legacy offenders;
conservative by design so a --- horizontal rule or a status = line mid-body never fires], each
rule independently tested; exits non-zero when any finding exists so it's a CI gate;
--json/--scope
Heuristic near-duplicate detection — pure tokenize/similarity [zero-dep Jaccard word-token
overlap, NOT embeddings]/clusterDuplicatesBlocked [inverted-index token-blocking,
O(candidate_pairs) not O(n²), produces identical clusters to the oracle clusterDuplicates when
Jaccard ≥ threshold > 0]; population is capped at 2000 entries for memory safety — when the cap is
hit, a narrowing warning is printed; --key-prefix and --since narrow the population;
--cluster-by-key <regex> switches from value-overlap to KEY-shape clustering [pure
compileKeyPattern/clusterByKeyPattern: entries whose keys share the same first capture group —
or full match — of the regex form one family, catching coordinate-key debt like many
pr{N}-{commentId} rows for one comment that the Jaccard heuristic misses when values differ; a
bare flag or unparseable regex is a usage error at exit 1, and so is combining it with an explicit
--threshold (key mode has no similarity cutoff, so the flag would otherwise be silently ignored —
a repo-level dedupe.threshold stays a value-mode default and never breaks a key-mode run);
--json reports mode:"key" + keyPattern and each cluster carries keyGroup instead of a
similarity range]; per-store across scopes; --json/--scope/--threshold [default 0.8]
Checks a changed-file set against a declarative Surface-Partner Map —
src/shared/obligations-map.mjs — of known, path-keyed file partnerships mined from EXISTING CI
guards [edge-parity.spec.ts, tool-catalog-parity.spec.ts, gen-surfaces.mjs --check,
render.spec.ts, sections.spec.ts, sync-plugin-skill.mjs --check]; a pure matcher
[src/shared/obligations-pure.mjs] resolves each matched entry's obliges (partner paths,
run:<action> advisories that never gate, or an "any of" OR-group satisfied by whichever of several
candidates is present) against the given changed-set and flags any PATH obligation not itself in the
set — the machine version of the recurring review finding "you fixed one surface and left its
partner stale," citing the memory lesson each partnership encodes. {name}/**/{name} in a
match/obliges glob binds a mirrored module's relative path [directories + stem, extension
stripped] so one entry covers every module instead of one per file where the partner's path
genuinely IS a predictable function of the source's. The edge-mirror/edge-mirror-core entries do
NOT use it: a real edge (Deno) mirror does not reliably preserve mcp-core's directory structure
[e.g. packages/mcp-core/src/auth/auth-token.ts mirrors the FLAT
supabase/functions/mcp/auth-token.ts, not a reconstructed .../mcp/auth/auth-token.ts], so those
two entries instead enumerate every known pair's exact partner from the single-source
src/shared/mirror-pairs.mjs inventory — the SAME one
packages/mcp-core/src/edge/edge-parity.spec.ts reads for its byte-comparison drift guard, so the
spec and the command can never disagree about which files mirror which. Changed-set: positionals
unioned with --files <path>, falling back to newline-separated stdin only when neither is given
(git diff --name-only | lorekit obligations). Cwd-independent — it matches the path STRINGS it
is given and never reads the filesystem or resolves scope from the current directory. Each entry
declares a state — advisory (reported, gates nothing) or gating (fails --strict) — and
an entry may only be gating if it has a guard, an independent source that already asserts the
partnership; without one the entry asserts only its author's belief, which is why perf-index and
error-code-doc are advisory (obligations.test.mjs enforces the coupling). A retired entry is
not reported at all. Each entry also names the recurrence class it instantiates
(src/shared/recurrence-clusters.mjs) rather than a bare lesson key, so two unmet obligations
sharing a root cause read as one problem and the class carries WHY it recurs; the class supplies the
memory key to cite, an entry-specific lessonKey overriding it. owner/added/reviewBy are the
lifecycle — reviewBy is the map's decay pressure, the counterpart to a memory's expires, without
which the map is the one place in the system that only accumulates. Exits 0 by default; --strict
exits non-zero when a gating entry has an unmet path obligation, --strict-all on ANY unmet
obligation regardless of state. --json → { files, matched, unmet, unmetGating, ok, okGating }.
CLI-only [native, no MCP tool/REST route, no tool-catalog.ts entry] — slice 1 of a larger
design; hook wiring at edit time and server-side retrieval changes are named follow-ups, not built
here
The compile pipeline's candidate scan — cluster → groom-merge → compile candidate → invariant — a
READ-ONLY survey that reuses dedupe's Jaccard clustering [value-mode only; --cluster-by-key
stays dedupe's, since key-shape debt is a naming signal, not a recurrence-candidate one] over the
memory store, then ranks the clusters worth compiling into a hand-written obligations-map.mjs
entry. A cluster is a candidate when the SUMMED seen_count across its members is at least
--min-seen-count [default 3], OR any member already declares a non-"active" status — read from a
status::<value> TAG [the convention the lorekit-setup skill prescribes, since a lesson body is
markdown for humans and carries no hidden metadata], falling back to a legacy <!-- meta: … status=… --> comment for lessons written before that — reported verbatim, never validated against a fixed
vocabulary, since none is defined anywhere in this codebase. Ranked by [summed seen_count ×
distinct scopes], descending. For each candidate it prints EVERY memory the merge would collapse —
that list is the whole point of the command. It reuses the SAME resolveRecurrenceClass join
dedupe uses [src/shared/recurrence-clusters.mjs], so a candidate whose members already resolve
to a named class is flagged as a stronger case than a merely-similar one. It deliberately does NOT
classify a lesson's applicability signal [its visible **Applies when:** line, or a legacy meta
comment's trigger-context] into a glob/command/error-shape [that judgment is the human step the
compile pipeline's "never auto-compile, never auto-gate" rule protects — the raw string is printed,
never interpreted], and it does NOT know about compiled_to [no such field exists yet — no schema,
no server support — so an already-compiled candidate can still surface here; a known, named gap, not
a silent omission]. Offline + Remote split like dedupe, same population cap; reads raw store rows
directly rather than through gather()/gatherStream(), whose normalizeEntry strips
seen_count. --json → { root, scopes, minSeenCount, offline, remote }, each candidate carrying
members [scope, key, seenCount, meta], score, and recurrenceClass. CLI-only [native,
no MCP tool/REST route, no tool-catalog.ts entry] — matching how obligations is registered
Alias url — print a shareable dashboard deep-link URL to stdout, nothing else, so it pipes
into a clipboard/PR/Slack: no-arg → the cwd's most-specific scope, <scope> → the scope-filtered
Explorer, <scope> <key> / <scope::key> → a lesson detail-sheet link setting BOTH ?lesson= and
?scope= (?lesson= carries a JSON-encoded {scope,key}, NOT a memory id — if what you hold
is the id a write returned, the param is ?memoryId=<uuid>, or just let this command build the
URL; /lore now also accepts a raw or JSON-quoted UUID in ?lesson= so the wrong combination
resolves instead of silently doing nothing, but memoryId is the spelling to write. See
deep-links), with --key naming a key that
itself contains :: (the same escape hatch show/write have); filter flags mirror the Explorer
[--q/--owner/--range|--from|--to/--archived], --base/LOREKIT_APP_URL override the
host [default https://lorekit.io], --json → { url, surface, base, params }. The read commands
show/search/list/tree also take a --link flag that short-circuits to the equivalent URL
for the current invocation. All URLs are built by the dependency-free
src/shared/deeplink-pure.mjs [the lessons-pure.mjs zero-import precedent], which encodes
every param encodeURIComponent(JSON.stringify(value)) and omits any param equal to its
useUrlState default — the exact inverse of the web app's read
[packages/web/src/lib/hooks/useUrlState.ts], mirroring ScopeHealthCard.tsx. core/lessons.mjs's
writeConfirmation [the hook link] now delegates to that module, fixing a real bug: it
previously raw-encoded ?scope=<scope>, which the dashboard's JSON.parse rejected into an
all-scopes fallback; write-confirmations now deep-link to the exact lesson
BYOD only (byod.md). Probes the project named by LOREKIT_STORAGE_URL with
LOREKIT_STORAGE_ANON_KEY or LOREKIT_STORAGE_SERVICE_KEY (the same probeByodStorage as
doctor), reports whether the LoreKit schema is already there, and prints the three routes that
apply it: psql -v ON_ERROR_STOP=1 -f supabase/byod/bootstrap.sql, the SQL Editor, or
supabase link + supabase db push followed by supabase/byod/storage-overlay.sql (which
bootstrap.sql already includes). It runs no DDL — PostgREST, the only thing a Supabase API key
reaches, cannot. The published package does not ship supabase/, so outside a LoreKit checkout it
prints the file's download URL instead of a local path. It never prints a key: the psql
connection string's password is the database password, not the service-role key.
Relocate a LoreKit-format local store, or push one to the hosted store — --from <path> is required
and the source is read by each file's frontmatter scope, so the source layout need not match the
destination. --to home|project forces a single local tier and the default routes each entry by
scope through the two-tier store; --to remote pushes the source store up to the hosted one,
which is the local→remote transition that previously had no bulk path. Dry-run by default with a
per-scope add/update/noop report; --yes/--apply writes; idempotent — a re-run is all-noop. The
remote destination resolves its connection through the same control model every other command reads
[loadControl], so a deny: remote still wins, and it PREFLIGHTS before the first request: a
missing connection or a read-only lk_ro_* token fails in one line rather than mid-push, a
write-only lk_wo_* token warns and then pushes WITHOUT classifying (its reads are denied, so
asking would 403 every entry — the hosted upsert is idempotent either way), and an unrecognized
prefix warns and proceeds so a self-hosted token still works. --endpoint/--token override the
resolved connection, which withOverrides alone cannot carry because resolveProjectConnection
reads process.env directly. Fidelity is bounded by what the hosted write can express —
scope/key/value/source_agent/trigger transfer as the source states them [value is
TRIMMED server-side by MemoryWriteSchema] and created travels as created_at, honoured on
INSERT only, so a lesson NEW to the hosted store keeps its ranking recency while one already there
keeps the date it has [and an unusable created is dropped for the write instant, reported as
createdAtDropped]. Three fields are not verbatim in ways worth stating outright: tags are
AUTHORITATIVE [tags = excluded.tags, so an untagged local entry CLEARS the hosted labels — the one
field where a verbatim upsert removes hosted data], origin_* is STICKY [coalesced, so an entry
carrying none leaves whatever the hosted row had, and no parameter overrides that], and
kind/host are never sent [the server infers both from the loop:: tag]. updated is re-stamped
by the server and the seen_count tally is server-derived [migration 00059: a write IS the next
sighting, so a key the hosted store already holds lands at ITS count plus one, not at 1], neither
having a write parameter; expires_at converts to the remaining whole ttl_days, clamped to the
schema's 1–365 — a clamp is lossy, so putEntry reports ttlClamped and the run NAMES each
shortened entry, in the dry run as well as the apply. Idempotency therefore compares only what a
write can change AND compares it the way the server stores it [sameRemoteEntry]: value trimmed,
only the origin_* fields the SOURCE carries (the RPC coalesces provenance, so a local entry with
none could never match a hosted row that has some), and an expiry that still HONOURS the local
intent rather than matching its instant — the hosted one is fixed at push time while the local one
is measured from now, so it is judged on whether at least half the intended life remains
(TTL_HONOURED_FRACTION, the ask capped at the API maximum). Matching instants, or asking whether
the hosted row outlives a freshly-computed intent, drifts with the clock and re-pushes forever; a
genuinely SHORTER hosted TTL still does re-push. Each was a permanent-UPDATE loop before it was
mirrored. A remote NOOP means "the hosted lesson already says this", not "byte-identical row".
tags REPLACE the hosted labels and origin_* is sticky, so both are reported rather than implied,
and every lossy outcome [clamped TTL, dropped created] is named per entry in the DRY RUN as well
as the apply, computed by one shared remoteWriteLosses so a preview cannot promise what the apply
does differently. Archived and expired entries are SKIPPED and counted, never pushed, because the
hosted write inserts a SECOND live row beside an archived one rather than reviving it [every
conflict predicate is partial on archived_at is null] and any TTL re-dates an expired row into the
future. Both rate-limit guards are client-side [src/store/rate-limit.mjs]: a sliding-window pacer
holds the run under a self-imposed 100/min below the server's 120/min — announcing itself the first
time it waits, and re-paced on every retry attempt so the ceiling holds during the 429 episode it
exists to prevent — and a 429 is still retried on the server's own Retry-After because the limit
is per USER and a concurrent agent shares it. isRetryable also covers a 5xx and a transport error,
the likeliest failure on a bulk push; a 4xx stays a decision. Widening it needed a bail-out, so five
CONSECUTIVE failures abandon the run with a partial-progress report [reset by any success], rather
than paying the full retry budget per entry through a systematic outage. The memory cap is
translated to a 429 too, so it is told apart by code: memory_cap and treated as TERMINAL — the run
stops, reports how many entries landed, and exits non-zero with guidance instead of retrying a write
that cannot succeed. It reads LoreKit's own on-disk format only — not persistent-memory's
~/.agent-memory/<bucket>/ — and v1 has no --org <slug> org-owned write and no reverse
remote→local direction
(aliases: rm → delete) (the removal lifecycle, the counterpart to write — archive <scope::key> soft-hides a lesson (reversible), delete <scope::key> [--force] soft-archives or
hard-deletes with --force, restore <scope::key> un-archives. All three address a memory the same
<scope::key> / <scope> <key> / --scope+--key way write/list/show do, and pick a store
with write's precedence [remote when usable else local, forced by --remote/--local].
Server-side they map to the hosted memory.archive/memory.delete/memory.restore, which are
scope-authorized for an API token by its allowlist [migrations 00071/00072]: a key scoped to a
scope may manage every writer's row in it, an unscoped key only its own, and a 0-row result is
reported as not-found vs forbidden rather than a silent false)
The two account-wide maintenance sweeps, over POST /memories/purge and /purge-expired — purge [--retention-days <1..365>] hard-deletes ARCHIVED lore past the window [default derived from the
tool catalog's PURGE_RETENTION_DAYS_DEFAULT, so the help cannot promise a default the server does
not apply], purge-expired hard-deletes TTL-expired lore and takes no options. Both are
remote-only — they sweep server-side state and the offline store has no equivalent, so --local
is REFUSED explicitly rather than silently succeeding against a store it cannot affect — and both
are irreversible, so they are gated by a confirmation rather than a dry run: no dry run is
possible, because the purge RPCs return their count only AFTER deleting and the REST dry-run
header stops before the write with nothing previewable, so "would purge N" cannot be answered
honestly. An interactive terminal is prompted; --yes is REQUIRED whenever there is nobody to ask
(a pipe, CI, or --json), so an unattended agent loop cannot purge by omission. --retention-days
is validated 1–365 client-side before any request, and a non-integer is rejected rather than
coerced. A token carrying a scope allowlist is refused by the server (ACCOUNT_WIDE_TOOLS in
_shared/auth/account-wide-tools.ts — an account-wide sweep has no scope to check and no result set
to narrow), and that 403 is printed verbatim plus a one-line next step; the CLI makes exactly
one request and never retries, splits the sweep or re-scopes to work around it
Retention automation, mapping onto the MCP groom.*/policy.*/memory.protect tools and the REST
/groom/*//policies*//protect routes — remote-only, matching purge, since retention_policies
has no local-store equivalent. groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes]
previews (default) or, with --run, archives (soft, recoverable via restore) every lesson a saved
policy or an inline condition set matches; exactly one of --policy-id/--scope is required, and
--run without --yes in a non-interactive context is refused rather than guessed at. policy <list|create|update|delete> manages the SAVED rules --policy-id runs — create --scope <s> --name <n> and update <id> both accept the same condition set, delete <id> removes the rule only (never
the lessons it matched). protect <scope::key> [--off] (or the shorter pin/unpin) excludes a
lesson from every policy and every groom --run regardless of which rule would otherwise catch it.
Every condition is the AND of five age/activity fields (--min-age-days/--unseen-days/
--max-seen-count/--max-read-count/--max-opened-count) plus the same eight dimension filters
the Lore Explorer's filter bar offers — --tags/--kind/--host/--trigger/--source-agent/
--origin-repo/--origin-branch/--origin-pr, each accepting a comma-separated value list (the same
convention write --tags a,b,c already uses) and each paired with its own --<dim>-mode (--tags-mode any|all|none, every other dimension in|nin). One shared parser, parseDimensionConditions in
shared/flags.mjs, backs policy create/policy update/groom so the three cannot describe the
dimensions differently — mirroring the edge's own single source, GROOM_DIMENSION_FIELDS in
supabase/functions/mcp/tools.ts. policy update pairs every condition (age/count AND every
dimension) with a --clear-<field> boolean that sends an explicit null rather than omitting the
field — the two are NOT the same request: omitting a field on update leaves it unchanged, null
clears it. RemoteStore.policyUpdate forwards the whole patch verbatim (including an explicit
null) via a PATCH-only stripUndefinedKeepNull that drops undefined but keeps null — deliberately
NOT the stripUndefined every other remote write uses, where a null and an omitted field mean the
same "not filtered" and both should be dropped.
The shared hook engine behind the plugins — injects the
ranked-then-bucket-capped-then-MMR-diversified top lessons on SessionStart [sessionRankOpts
seeds a relevance query from the branch name (branchQueryTerms distils scope.branch after
dropping the leading type/author segment; a trunk branch adds none; off via
hooks.sessionStart.branchHint: off) at the DEFAULT relevance weight — it only LIFTS an on-topic
lesson (a non-matching branch is byte-for-byte the old read), deliberately not damped by a smaller
weight, which would shrink the score normaliser and distort the diversifier's MMR → rankLessons →
capPerBucket (at most hooks.sessionStart.loopCap — default 1, 0 excludes loop buckets — per
loop::<bucket> via loopBucketOf, so one prolific self-improvement loop cannot flood a general
session; non-loop lessons uncapped) → diversifyRankedLessons applies the same selectDiverse
λ=0.7 lexical-Jaccard pass the hosted order=rank path uses, so several near-identical iteration
logs no longer take every slot. The block is bounded from BOTH directions —
hooks.sessionStart.maxChars bounds its characters and hooks.sessionStart.maxLessons its LINE
count (default 100, bounded 3–200, clamped not rejected); whichever binds first decides the block,
and at the default budget that is almost always maxChars. So maxLessons is a read-DEPTH dial,
not a size one: ONE resolved ceiling is spent on the fetch, the diversifier's k AND the render,
and since the budget runs out around line 25 its working effect is the size of the candidate pool
the ranker picks from. The pure scopeReadLimit(maxLessons) is clamp(ceiling, SCOPE_READ_LIMIT, MAX_STORE_LIST_LIMIT) — 100/scope by default (400 candidates across a four-scope hierarchy, versus
the newest 25 before), never past MAX_STORE_LIST_LIMIT = 100 (the max(100) on
ListMemoriesQuerySchema, the schema validating GET /memories — NOT the MemoryListSchema MCP
tool schema — mirrored self-contained per the limits.ts pattern, since an over-cap limit 400s
and this best-effort path skips a failed scope, so the whole block would come back silently EMPTY),
and never below SCOPE_READ_LIMIT so a lowered ceiling still ranks from a full pool.
HARD_LESSON_CEILING survives as the absolute 200 clamp, applied a second time inside
core/lessons.mjs because fetchLessons/formatLessons take maxLessons as a plain option that
never passes through the config normaliser], a per-turn relevance pull on UserPromptSubmit
[promptQuery distils terms from the prompt → promptLessonsFromStore ranks the store's hits and
injects at most three the session has not already been shown, tracked by core/state.mjs's
append-only shown-set; gated by hooks.userPrompt (on default | off) and run under
PROMPT_FETCH_TIMEOUT_MS rather than restFetch's 10s default, because it sits on the user's
critical path], relevant lessons + a write-nudge on a tool failure [failureQuery →
relevantLessonsFromStore QUERIES the store — ONE store.search carrying all terms (OR) across the
scope hierarchy, deduped/capped by the pure dedupeRelevant — not a post-filter of the injected
set], and a friction-gated retrospective nudge on Stop [hooks.stop: friction default |
always | off; core/friction.mjs's detectFriction/shouldRetrospect read the transcript and
stay silent on a clean session]. SessionStart also carries a skill-update nudge [gated by
updates.notify: auto default | off; when a shipped skill has drifted past what is installed
(checkSkillVersions, the same offline compare doctor and update use), core/update-notify.mjs
appends one terse line — LoreKit skills are outdated (<skill> v<installed> → v<shipped>). Run \lorekit update` to refresh.— after the lessons block. Throttled to at most once per NEW shipped version plus a 7-day cooldown on repeating the same version, tracked in$LOREKIT_HOME/update-state.json(never created whenupdates.notifyisoff`); any error is
swallowed and nothing but that state file is ever written]; always exit 0, best-effort, never blocks
the host
A hand-rolled local stdio MCP server exposing memory.* from the resolved store — lets .mcp.json
point at the CLI instead of mcp-remote, for offline local-mode tool calls
(prints a shell completion script for zsh or fish via lorekit completion <shell>,
generated from the command registry through the standalone completionSpec in
src/shared/completions.mjs so commands, aliases and each command's own flags stay in sync — a
test/completion.test.mjs parity check holds the spec to the registry and to bin's KNOWN_FLAGS,
since a completion cannot import commands.mjs without closing an import cycle through install.
The generated scripts also complete scopes (--scope) and scope::key addresses
(show/write/archive/delete/restore/link positionals) by calling back into lorekit completion --complete scope|key, which reads the LOCAL store only — a TAB stays instant and never
prompts for a token or hits the network, at the cost of a remote-only scope not appearing. It is
machine alongside hook/mcp: its stdout is a shell contract and the --complete callback fires
on every TAB, so it is METERED, never traced).
Ships four skills under skill/* — lorekit-memory (runtime read/write), lorekit-setup
(authoring a self-improvement loop into a host), lorekit-groom (a propose-then-confirm
grooming pass over an accumulated store: survey→lint→dedupe/merge→expiry→prune, analysis via the CLI
read commands and mutation via the lorekit archive/delete/restore commands or the memory.*
MCP tools), and lorekit-self-host (a slash-only guided deployment runbook). install/uninstall/doctor
iterate the SKILLS list in config.mjs, and scripts/codegen/sync-plugin-skill.mjs mirrors every
skill/* into the plugin regardless of whether it's in SKILLS.
lorekit-self-host is deliberately NOT in SKILLS. doctor.mjs reports fail — "not found —
run lorekit install" — for every SKILLS entry missing from an install, so adding it there would turn
every existing install's doctor red until a re-install, and would scaffold a one-time deployment
runbook onto every hosted user who never needs it. It still reaches users two other ways: the Claude
plugin vendors it (sync-plugin-skill.mjs mirrors every directory under skill/*, not just the
SKILLS subset), and the npm tarball's files field includes skill/, so a non-plugin user can fetch
skill/lorekit-self-host/SKILL.md directly.
Human-facing commands
(install/uninstall/doctor/list/search/show/stats/scopes/diff/tree/lint/dedupe/link/migrate/archive/delete/restore/purge/purge-expired)
emit one OTel span + counter point per run via a zero-dep self-contained OTLP/JSON fetch exporter
(src/telemetry/telemetry.mjs, the _shared/telemetry/otel.ts pattern) — service.name=cli,
service.namespace=lorekit, bounded attrs carrying no path/cwd/token/endpoint/repo/scope, opt-out
via LOREKIT_TELEMETRY/DO_NOT_TRACK/telemetry.disabled. hook/mcp/completion stay UNTRACED
but are now METERED — meterCommand emits the counter data point alone (no span) on a 400ms budget,
started before the command and awaited after so it overlaps its work, because those are the
highest-volume entry points (completion --complete fires on every TAB) and leaving them silent hid
the traffic that dominates. Runs are ATTRIBUTABLE via src/telemetry/telemetry-identity.mjs: an
opaque random install id on the resource as service.instance.id, plus user.id = the LoreKit
account once known (learned from the X-LoreKit-User-Id response header the edge REST router
returns on any authenticated call, then cached) else install:<id> — which is what lets a fully
OFFLINE run still join to server-side auth.user_id/usage_events. Both live in
$LOREKIT_HOME/telemetry-id.json; doctor prints the path and both values, and deleting the file
resets the identity. Three invariants hold it: nothing is minted and NO FILE IS CREATED while
telemetry is disabled, the account cache only ever UPDATES an existing file (never creates one, so
an opted-out user's id stays off their disk even though they still receive the header), and an id
that cannot be persisted reports as NO identity rather than a fresh one per run (which would inflate
the install count to equal the invocation count). The ingesting-only Dash0 token is never committed
— src/telemetry/telemetry-token.mjs is empty in git and injected at publish time from the
LOREKIT_TELEMETRY_TOKEN secret by scripts/telemetry/inject-telemetry-token.mjs (wired into
release.yml's publish-cli job); LOREKIT_TELEMETRY_TOKEN/OTEL_EXPORTER_OTLP_HEADERS env
override it at runtime. Verified by its own node:test suite; excluded from the NX TS lint gate.
restFetch (src/shared/mcp.mjs) tags every REST call with two dimensions usage_events groups
on, so GET /memories/usage//read-ranking and the correlation-scoped views can answer "usage for
this PR/run/session" without a human exporting anything by hand:
X-LoreKit-Correlation-Id— an opaque grouping key (a PR ref, a CI run, a session id). An explicitLOREKIT_CORRELATION_IDenv var always wins. OtherwisederiveSessionContext(same file) derives one from the ambient environment: a resolvable PR number (LOREKIT_PR/GITHUB_REF: refs/pull/<n>/merge/GITHUB_PR_NUMBER, reusingorigin.mjs'sprNumberFromEnv) plus a known repo (GITHUB_REPOSITORY) yieldspr:<owner>/<repo>#<n>; a CI environment (GITHUB_ACTIONS/CI) with a repo andGITHUB_RUN_IDyieldsci:<owner>/<repo>#<run_id>; a known host session id (LOREKIT_SESSION_ID, thenCLAUDE_CODE_SESSION_ID, then the legacyCLAUDE_SESSION_ID) yieldssession:<id>. Every derived value is validated by the same[A-Za-z0-9_-./:#@], 200-char bound as an explicit one — a value that fails it is dropped rather than sent malformed. The raw session id itself is never logged or stored anywhere beyond this derived correlation id.- Hook fires carry the host's own session id, without any env var.
lorekit hookpublishes the session id its adapter already parses off the stdin payload (Claude'ssession_id, Codex'sthread_id/session_id, Cursor'sconversation_id/generation_id) asLOREKIT_SESSION_IDfor the rest of that one invocation, so a hook-driven read or write is grouped into a run on every host — including Cursor and Codex, which export no session env var at all. An explicitLOREKIT_SESSION_ID(orLOREKIT_CORRELATION_ID) still wins; the payload only fills a gap. X-LoreKit-Session-Kind— the bounded dimension charts actually group on (local/ci/pr/unknown;correlation_idstays the unbounded drill-down key, one bucket per run). Set alongside the correlation id by the SAME derivation, so the two never disagree about which case fired.unknownmeans no derivable context — not an error, and the hook/CLI still exits 0 on every path.
Both are fail-safe and TOTAL: deriveSessionContext never throws, reads only env, and a run with
nothing derivable reports sessionKind: 'unknown' with no correlation id rather than guessing. The
edge validates whatever it receives (parseSessionKind, closed vocabulary) and never derives —
derivation is CLI-only.