@lorekit/cli 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -20
- package/bin/lorekit.mjs +14 -13
- package/package.json +1 -1
- package/skill/lorekit-memory/SKILL.md +6 -0
- package/skill/lorekit-setup/SKILL.md +68 -0
- package/skill/lorekit-setup/rules/self-improvement-loops.md +257 -0
- package/src/config.mjs +16 -7
- package/src/core/lessons.mjs +153 -9
- package/src/doctor.mjs +19 -13
- package/src/hook.mjs +25 -2
- package/src/install.mjs +17 -13
- package/src/lessons-pure.mjs +65 -0
- package/src/lessons-view.mjs +0 -0
- package/src/scope.mjs +5 -1
- package/src/store/local.mjs +1 -1
- package/src/tree.mjs +13 -11
- package/src/uninstall.mjs +15 -8
package/README.md
CHANGED
|
@@ -51,9 +51,11 @@ without needing a marketplace:
|
|
|
51
51
|
2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
|
|
52
52
|
MCP config (preserving any other servers).
|
|
53
53
|
3. **Hooks** — the *deterministic* layer: lessons injected on every
|
|
54
|
-
`SessionStart`,
|
|
55
|
-
|
|
56
|
-
and
|
|
54
|
+
`SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
|
|
55
|
+
look **relevant to that failure** ("you've hit this before") plus a nudge to
|
|
56
|
+
record the fix, and a retrospective nudge on `Stop`. These fire the shared
|
|
57
|
+
`lorekit hook` engine and are merged into `settings.json` (existing hooks
|
|
58
|
+
preserved).
|
|
57
59
|
|
|
58
60
|
It first asks **where** to install:
|
|
59
61
|
|
|
@@ -212,10 +214,10 @@ rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
|
212
214
|
|
|
213
215
|
### `lorekit tree` (alias `resolve`)
|
|
214
216
|
|
|
215
|
-
Show the scopes the hooks actually **inject** — branch → repo →
|
|
216
|
-
precedence order (most-specific first) — as a resolution hierarchy,
|
|
217
|
-
any key present at more than one scope which scope's lesson **wins**
|
|
218
|
-
**shadowed**:
|
|
217
|
+
Show the scopes the hooks actually **inject** — project → branch → repo →
|
|
218
|
+
global, in precedence order (most-specific first) — as a resolution hierarchy,
|
|
219
|
+
and mark for any key present at more than one scope which scope's lesson **wins**
|
|
220
|
+
and which are **shadowed**:
|
|
219
221
|
|
|
220
222
|
```bash
|
|
221
223
|
lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
|
|
@@ -224,12 +226,12 @@ lorekit tree --json # per-entry { winning, shadowedBy } + a winners[]
|
|
|
224
226
|
```
|
|
225
227
|
|
|
226
228
|
This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
|
|
227
|
-
in `readOrder` (branch → repo → global) and keeps the first value seen
|
|
228
|
-
a more-specific scope overrides a broader scope's same-key lesson. It
|
|
229
|
-
"which lesson actually applies here, and what is being overridden?".
|
|
230
|
-
`project::` scope is
|
|
231
|
-
|
|
232
|
-
Each store is resolved independently, in the same Offline / Remote split.
|
|
229
|
+
in `readOrder` (project → branch → repo → global) and keeps the first value seen
|
|
230
|
+
per key, so a more-specific scope overrides a broader scope's same-key lesson. It
|
|
231
|
+
answers "which lesson actually applies here, and what is being overridden?". The
|
|
232
|
+
`project::` scope **is** part of the injected set (project is the most-specific
|
|
233
|
+
scope), so `tree`, the hooks, and every read command's `scopeList` now share one
|
|
234
|
+
ordering. Each store is resolved independently, in the same Offline / Remote split.
|
|
233
235
|
|
|
234
236
|
### `lorekit lint`
|
|
235
237
|
|
|
@@ -273,8 +275,9 @@ range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store
|
|
|
273
275
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
274
276
|
It is not run by hand — the plugins wire it into their hook config. It reads
|
|
275
277
|
the host framework's JSON on stdin and prints that host's injection format on
|
|
276
|
-
stdout (lessons at session start; a nudge on
|
|
277
|
-
always exiting 0 so it can never
|
|
278
|
+
stdout (lessons at session start; relevant lessons plus a write-nudge on a tool
|
|
279
|
+
failure; a retrospective nudge at end of turn), always exiting 0 so it can never
|
|
280
|
+
block the host agent.
|
|
278
281
|
|
|
279
282
|
```bash
|
|
280
283
|
lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>
|
|
@@ -474,9 +477,11 @@ active deny constraints.
|
|
|
474
477
|
| `LOREKIT_TELEMETRY_TOKEN` | bearer token for telemetry export (overrides the baked-in default) |
|
|
475
478
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` | override the telemetry OTLP endpoint / headers |
|
|
476
479
|
|
|
477
|
-
## What the
|
|
480
|
+
## What the skills do
|
|
478
481
|
|
|
479
|
-
|
|
482
|
+
`install` scaffolds two skills:
|
|
483
|
+
|
|
484
|
+
The **`lorekit-memory`** skill teaches an agent to:
|
|
480
485
|
|
|
481
486
|
- **Read** scoped lessons at the start of a task, on first navigation into
|
|
482
487
|
unfamiliar code, and before risky operations (narrow-to-broad scope merge).
|
|
@@ -488,11 +493,16 @@ This mirrors the read-on-start / write-on-failure loop of the `aw`
|
|
|
488
493
|
autonomous-workflow agent. See the skill's own `SKILL.md` for the full
|
|
489
494
|
protocol.
|
|
490
495
|
|
|
491
|
-
The
|
|
496
|
+
The **`lorekit-setup`** skill is the authoring counterpart: it teaches an agent
|
|
497
|
+
to wire a self-improvement loop into one of *your own* skills or workflows — the
|
|
498
|
+
two-tier model, the lesson bucket convention, and the entrenchment guards. See
|
|
499
|
+
its `SKILL.md` and `rules/self-improvement-loops.md`.
|
|
500
|
+
|
|
501
|
+
The **skills** are model-invoked (the agent chooses to use them). For a
|
|
492
502
|
**deterministic** guarantee — lessons injected on every session start, a nudge
|
|
493
503
|
on every tool failure — use the framework plugins in [`plugins/`](../../plugins/),
|
|
494
|
-
which fire the `lorekit hook` engine on host lifecycle events. The
|
|
495
|
-
the hooks compose: hooks guarantee the *timing*, the
|
|
504
|
+
which fire the `lorekit hook` engine on host lifecycle events. The skills and
|
|
505
|
+
the hooks compose: hooks guarantee the *timing*, the skills supply the
|
|
496
506
|
*authoring judgment*.
|
|
497
507
|
|
|
498
508
|
## Testing & validating across frameworks
|
package/bin/lorekit.mjs
CHANGED
|
@@ -32,14 +32,14 @@ ${c.bold('Usage')}
|
|
|
32
32
|
npx @lorekit/cli <command> [options]
|
|
33
33
|
|
|
34
34
|
${c.bold('Commands')}
|
|
35
|
-
install Scaffold the lorekit-memory
|
|
36
|
-
and install the deterministic hooks (lessons
|
|
37
|
-
nudge on tool failure + at Stop). Prompts to
|
|
38
|
-
project (.claude) or globally for every project
|
|
39
|
-
--project / --global choose non-interactively,
|
|
40
|
-
the hooks (
|
|
41
|
-
uninstall Reverse install: remove the lorekit-memory
|
|
42
|
-
entry, and the lifecycle hooks for the chosen scope. Surgical —
|
|
35
|
+
install Scaffold the lorekit-memory + lorekit-setup skills, wire the
|
|
36
|
+
LoreKit MCP server, and install the deterministic hooks (lessons
|
|
37
|
+
on SessionStart, a nudge on tool failure + at Stop). Prompts to
|
|
38
|
+
install for this project (.claude) or globally for every project
|
|
39
|
+
(~/.claude); --project / --global choose non-interactively,
|
|
40
|
+
--no-hooks skips the hooks (skills stay model-invoked only).
|
|
41
|
+
uninstall Reverse install: remove the lorekit-memory + lorekit-setup skills,
|
|
42
|
+
the MCP server entry, and the lifecycle hooks for the chosen scope. Surgical —
|
|
43
43
|
other servers, hooks, and settings are left untouched. Prompts
|
|
44
44
|
project vs global; --project / --global choose non-interactively.
|
|
45
45
|
doctor Verify the skill install, MCP connectivity, token, and scope.
|
|
@@ -131,13 +131,14 @@ Run ${c.cyan('lorekit <command> --help')} for command-specific options.
|
|
|
131
131
|
// focused entry instead of the full top-level HELP, so a user only sees the
|
|
132
132
|
// flags that actually apply to what they're running.
|
|
133
133
|
const COMMAND_HELP = {
|
|
134
|
-
install: `${c.bold('lorekit install')} — scaffold the
|
|
134
|
+
install: `${c.bold('lorekit install')} — scaffold the skills, wire the MCP server, install the hooks
|
|
135
135
|
|
|
136
136
|
${c.bold('Usage')}
|
|
137
137
|
npx @lorekit/cli install [options]
|
|
138
138
|
|
|
139
|
-
Scaffolds the lorekit-memory
|
|
140
|
-
|
|
139
|
+
Scaffolds the lorekit-memory (runtime read/write) and lorekit-setup (loop
|
|
140
|
+
authoring) skills, adds the LoreKit MCP server, and wires the deterministic
|
|
141
|
+
hooks (lessons on SessionStart, a nudge on tool failure + at Stop).
|
|
141
142
|
|
|
142
143
|
${c.bold('Options')}
|
|
143
144
|
-d, --dir <path> Target project root (default: current directory)
|
|
@@ -159,8 +160,8 @@ ${c.bold('Examples')}
|
|
|
159
160
|
${c.bold('Usage')}
|
|
160
161
|
npx @lorekit/cli uninstall [options]
|
|
161
162
|
|
|
162
|
-
Removes the lorekit-memory
|
|
163
|
-
Surgical — other servers, hooks, and settings are left untouched.
|
|
163
|
+
Removes the lorekit-memory and lorekit-setup skills, the MCP server entry, and
|
|
164
|
+
the lifecycle hooks. Surgical — other servers, hooks, and settings are left untouched.
|
|
164
165
|
|
|
165
166
|
${c.bold('Options')}
|
|
166
167
|
-d, --dir <path> Target project root (default: current directory)
|
package/package.json
CHANGED
|
@@ -46,6 +46,12 @@ Both jobs run through LoreKit's `memory.*` MCP tools.
|
|
|
46
46
|
If those tools are not connected, this skill is a no-op — say so once and
|
|
47
47
|
continue the task; never block work because memory is unavailable.
|
|
48
48
|
|
|
49
|
+
> **Wiring a loop into your own skill?** This skill does the runtime read/write.
|
|
50
|
+
> To set up a *self-improvement loop* for a skill, workflow, or agent — so it
|
|
51
|
+
> reads its own lessons on every run and promotes proven ones into permanent
|
|
52
|
+
> rules — use the **`lorekit-setup`** skill, the authoring counterpart to this
|
|
53
|
+
> one.
|
|
54
|
+
|
|
49
55
|
> **Modes.** Memory has a controllable backend (`lorekit doctor` shows the
|
|
50
56
|
> resolved one): `remote` (the hosted LoreKit server — the default), `local`
|
|
51
57
|
> (markdown files in two tiers — a per-user **home** tier at `~/.lorekit/` and
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lorekit-setup
|
|
3
|
+
description: >
|
|
4
|
+
Sets up a self-improvement loop for a skill, workflow, or agent using LoreKit,
|
|
5
|
+
so a host gets better across runs by reading its own accumulated lessons at
|
|
6
|
+
the start of every run and hardening the proven ones into permanent rules.
|
|
7
|
+
Designs the two tiers (a fast episodic tier of LoreKit lessons, advisory-only;
|
|
8
|
+
a slow procedural tier that promotes a recurring lesson into a host rule),
|
|
9
|
+
chooses the lesson bucket (tag + key namespace) and scopes, and installs the
|
|
10
|
+
entrenchment guards that stop a learning loop from reinforcing its own
|
|
11
|
+
mistakes. Runtime reading and writing of lessons is the lorekit-memory skill;
|
|
12
|
+
this is the authoring counterpart. Use when giving a host durable cross-run
|
|
13
|
+
memory or wiring a lessons loop. Triggers on "set up memory for my skill",
|
|
14
|
+
"add a self-improvement loop", "give my workflow memory", "make this learn
|
|
15
|
+
from its mistakes", "self-improving memory", "/lorekit-setup".
|
|
16
|
+
user-invocable: true
|
|
17
|
+
argument-hint: '[host-name]'
|
|
18
|
+
license: MIT
|
|
19
|
+
metadata:
|
|
20
|
+
author: mthines
|
|
21
|
+
version: '1.0.0'
|
|
22
|
+
workflow_type: memory-loop-authoring
|
|
23
|
+
tags:
|
|
24
|
+
- lorekit
|
|
25
|
+
- self-improvement
|
|
26
|
+
- memory
|
|
27
|
+
- lessons
|
|
28
|
+
- loop
|
|
29
|
+
- authoring
|
|
30
|
+
- setup
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# LoreKit Setup
|
|
34
|
+
|
|
35
|
+
Give a skill, workflow, or agent a **self-improvement loop**: it reads its own
|
|
36
|
+
accumulated lessons at the start of every run and hardens the proven ones into
|
|
37
|
+
permanent rules — so it gets better the more it runs.
|
|
38
|
+
|
|
39
|
+
This is the **authoring** counterpart to `lorekit-memory`. `lorekit-memory` does
|
|
40
|
+
the runtime read/write of individual lessons; `lorekit-setup` wires the durable
|
|
41
|
+
loop that calls those primitives on a host's behalf. Both run on the same
|
|
42
|
+
LoreKit `memory.*` MCP tools.
|
|
43
|
+
|
|
44
|
+
## The two tiers (in one screen)
|
|
45
|
+
|
|
46
|
+
| Tier | Mechanism | Changes behavior? |
|
|
47
|
+
| ---- | --------- | ----------------- |
|
|
48
|
+
| **Fast (episodic)** | LoreKit lessons in a per-host bucket, read at the start of a run, written on failure | **No** — advisory input only |
|
|
49
|
+
| **Slow (procedural)** | A human-reviewed edit that hardens a recurring lesson into a host rule | **Yes** |
|
|
50
|
+
|
|
51
|
+
A recurrence gate connects them: a lesson that recurs (`seen_count >= 3`) or is
|
|
52
|
+
marked `status=structural` becomes promotion-eligible. Entrenchment guards keep
|
|
53
|
+
the fast tier from reinforcing its own wrong conclusions.
|
|
54
|
+
|
|
55
|
+
## Set up a loop
|
|
56
|
+
|
|
57
|
+
Follow [rules/self-improvement-loops.md](./rules/self-improvement-loops.md).
|
|
58
|
+
It covers: when to add a loop (and when not to), the bucket convention (tag
|
|
59
|
+
`loop::<host>-lessons` + key namespace), scope selection, the lesson schema, the
|
|
60
|
+
read/write steps, the promotion gate, the entrenchment guards, a wiring
|
|
61
|
+
checklist, and an interactive setup flow.
|
|
62
|
+
|
|
63
|
+
If invoked with a `host-name`, set up the loop for that host; otherwise ask
|
|
64
|
+
which skill / workflow / agent the loop is for, then walk the interactive setup.
|
|
65
|
+
|
|
66
|
+
The loop's runtime tier needs LoreKit's `memory.*` tools connected; if they are
|
|
67
|
+
not, the host's loop is a silent no-op (the slow tier — a normal source edit —
|
|
68
|
+
still works). Designing the loop needs no connection.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Setting up a self-improvement loop
|
|
2
|
+
|
|
3
|
+
Use this when someone wants a skill, workflow, or agent to **get better across
|
|
4
|
+
runs** — "give my skill memory", "add a self-improvement loop", "make this
|
|
5
|
+
learn from its mistakes", "set up lessons for this workflow".
|
|
6
|
+
|
|
7
|
+
Reading and writing individual lessons at runtime is the **`lorekit-memory`**
|
|
8
|
+
skill (its `intake` / `retrospective` rules). This skill is one level up: how to
|
|
9
|
+
wire a **durable loop** into a host so it reads its own accumulated lessons at
|
|
10
|
+
the start of every run and records new ones on failure — safely, without the
|
|
11
|
+
loop entrenching its own mistakes. The loop runs on the same `memory.*` MCP
|
|
12
|
+
tools `lorekit-memory` uses.
|
|
13
|
+
|
|
14
|
+
The design has two tiers connected by a recurrence gate. Both run on LoreKit.
|
|
15
|
+
|
|
16
|
+
## Contents
|
|
17
|
+
|
|
18
|
+
- [When to add a loop (and when not to)](#when-to-add-a-loop-and-when-not-to)
|
|
19
|
+
- [The two tiers](#the-two-tiers)
|
|
20
|
+
- [Conventions](#conventions)
|
|
21
|
+
- [Read step (start of every run)](#read-step-start-of-every-run)
|
|
22
|
+
- [Write step (on failure / at the end of a run)](#write-step-on-failure--at-the-end-of-a-run)
|
|
23
|
+
- [Promotion (fast → slow)](#promotion-fast--slow)
|
|
24
|
+
- [Entrenchment guards (do not skip these)](#entrenchment-guards-do-not-skip-these)
|
|
25
|
+
- [Wiring checklist](#wiring-checklist)
|
|
26
|
+
- [Interactive setup](#interactive-setup)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## When to add a loop (and when not to)
|
|
31
|
+
|
|
32
|
+
Add a loop when **all** of these hold:
|
|
33
|
+
|
|
34
|
+
- The host is an **orchestrator or multi-step pipeline** that can fail in
|
|
35
|
+
recurring, classifiable ways (wrong triage, a missed step, a false-green gate).
|
|
36
|
+
- Its failures are about the **host's own process**, not just the user's product.
|
|
37
|
+
- There is somewhere a proven lesson could eventually **harden into a rule** (the
|
|
38
|
+
slow tier below).
|
|
39
|
+
|
|
40
|
+
Do **not** add a loop to:
|
|
41
|
+
|
|
42
|
+
- **One-shot utilities** with no durable cross-run subject — the bookkeeping
|
|
43
|
+
costs more than it returns.
|
|
44
|
+
- **Adversarial / audit steps** (a red-team pass, a fresh-eyes reviewer) — they
|
|
45
|
+
must not be biased by prior runs; that is the whole point of a fresh pass.
|
|
46
|
+
- **Steps that handle secrets or credentials** — routing them through lesson
|
|
47
|
+
extraction is a leak risk.
|
|
48
|
+
|
|
49
|
+
When unsure, default to **no**. A loop can be added later; unwinding an
|
|
50
|
+
entrenched-bias loop is harder.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## The two tiers
|
|
55
|
+
|
|
56
|
+
| Tier | Mechanism | Storage | Changes behavior? | Gate |
|
|
57
|
+
| ---- | --------- | ------- | ----------------- | ---- |
|
|
58
|
+
| **Fast (episodic)** | LoreKit lessons (`memory.*`), read at the start of a run, written on failure / at the end | LoreKit | **No** — advisory input only | none (privacy pre-flight only) |
|
|
59
|
+
| **Slow (procedural)** | A permanent edit to the host's own source / rules | the host skill | **Yes** — a rule / gate / trigger | human review + approval |
|
|
60
|
+
|
|
61
|
+
The fast tier captures a lesson immediately and cheaply. A lesson earns a
|
|
62
|
+
**permanent** change to the host (the slow tier) only once it has **recurred
|
|
63
|
+
across runs** — recurrence is the cheap external signal that the lesson is real,
|
|
64
|
+
not a one-off. This is the episodic → procedural promotion path, with a human
|
|
65
|
+
gate on the slow tier so a single bad run can never rewrite the host.
|
|
66
|
+
|
|
67
|
+
The fast tier is optional: if LoreKit's `memory.*` tools are not connected, the
|
|
68
|
+
loop is a silent no-op (log one line, continue). The slow tier is just editing
|
|
69
|
+
the host and is unaffected.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Conventions
|
|
74
|
+
|
|
75
|
+
Give the loop its own **bucket** so its lessons stay separate from every other
|
|
76
|
+
loop's on the same scopes. A bucket is a tag plus a key namespace:
|
|
77
|
+
|
|
78
|
+
- **Tag:** `loop::<host>-lessons` (e.g. `loop::deploy-lessons`). Reads filter by
|
|
79
|
+
it; writes always carry it.
|
|
80
|
+
- **Key:** `<host>-lessons::<kebab-slug>` (e.g. `deploy-lessons::migration-order`).
|
|
81
|
+
Same `scope` + `key` overwrites in place — the mechanism behind recurrence
|
|
82
|
+
counting.
|
|
83
|
+
|
|
84
|
+
Pick the **scope** per lesson from the standard LoreKit model (`::` is the only
|
|
85
|
+
separator; the `lorekit-memory` skill's scope-resolution reference has the full
|
|
86
|
+
derivation):
|
|
87
|
+
|
|
88
|
+
- **`global`** — universal lessons that should follow the user across every repo.
|
|
89
|
+
- **`repo::{owner}/{repo}`** — lessons specific to this repository.
|
|
90
|
+
|
|
91
|
+
Reserve `branch::` for throwaway notes; a loop normally writes `global` or
|
|
92
|
+
`repo::`.
|
|
93
|
+
|
|
94
|
+
### The lesson record
|
|
95
|
+
|
|
96
|
+
A loop lesson is **procedural** ("how to do better next time"), not a fact about
|
|
97
|
+
the user. Keep the machine-read metadata in a `meta:` comment at the top of the
|
|
98
|
+
`value` so the prose stays readable:
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
<!-- meta: seen_count=1 status=active expires=<ISO 8601, ~90 days out> trigger-context="<concrete signal — file glob, task type, tool, error shape>" -->
|
|
102
|
+
|
|
103
|
+
# <one-line lesson title>
|
|
104
|
+
|
|
105
|
+
**What failed:** <concrete observable from the run>
|
|
106
|
+
**Why:** <root cause, if known; "unknown" is allowed>
|
|
107
|
+
**What to do next time:** <prescriptive, actionable, testable instruction>
|
|
108
|
+
**Promotion target:** <the host rule/step this would harden if promoted, or "none">
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`trigger-context` must be **concrete** (globs, task types, tool names, error
|
|
112
|
+
shapes) — never "when it feels relevant" — so the read step can match it
|
|
113
|
+
mechanically. `seen_count`, `status`, and `expires` drive recurrence, promotion,
|
|
114
|
+
and decay.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Read step (start of every run)
|
|
119
|
+
|
|
120
|
+
Read narrow-to-broad, filtered by the bucket tag, and merge:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
memory.list { scope: "repo::{owner}/{repo}", tags: ["loop::<host>-lessons"], limit: 50 } # skips silently if memory.* not connected
|
|
124
|
+
memory.list { scope: "global", tags: ["loop::<host>-lessons"], limit: 50 }
|
|
125
|
+
# when the run names a subsystem / error, add a search:
|
|
126
|
+
memory.search { q: "<keywords>", scopes: ["repo::{owner}/*", "global"], limit: 10 }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Then:
|
|
130
|
+
|
|
131
|
+
1. Match each lesson's `trigger-context` against the current run. Consider only
|
|
132
|
+
matches. **Skip any lesson whose `expires` is in the past** — treat it as
|
|
133
|
+
stale.
|
|
134
|
+
2. Apply each matching *"What to do next time"* as a **consideration**, not a
|
|
135
|
+
command — it biases the run unless it conflicts with the user's stated intent
|
|
136
|
+
or a task-specific constraint. On conflict, the user's intent wins; surface it.
|
|
137
|
+
3. On a `repo::` vs `global` collision, the `repo::` lesson wins (closer scope).
|
|
138
|
+
|
|
139
|
+
No consolidation pass is needed — LoreKit owns storage and deduplicates on write.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Write step (on failure / at the end of a run)
|
|
144
|
+
|
|
145
|
+
Trigger on friction: a stuck loop, a repeated failure, a gate that should have
|
|
146
|
+
caught something, a near-miss, a guess that paid off. Not on smooth successes.
|
|
147
|
+
|
|
148
|
+
1. **Classify the scope.** Trigger references a concrete repo path / repo-specific
|
|
149
|
+
package or term → `repo::{owner}/{repo}`. A glob, framework, tool, or task
|
|
150
|
+
type with no repo binding → `global`. When ambiguous, default to `global`.
|
|
151
|
+
2. **Deduplicate**, so a recurrence updates in place instead of piling up:
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
memory.search { q: "<key words of the lesson>", scopes: ["repo::{owner}/{repo}", "global"], limit: 10 }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
3. **Write** to the classified scope:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
memory.write {
|
|
161
|
+
scope: "<global | repo::{owner}/{repo}>",
|
|
162
|
+
key: "<host>-lessons::<slug>",
|
|
163
|
+
value: "<the lesson body above>",
|
|
164
|
+
tags: ["loop::<host>-lessons", "source::<trigger>"],
|
|
165
|
+
trigger: "<stuck-loop | command-failure | gotcha | near-miss | assumption-wrong | paid-off | manual>"
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Same `scope` + `key` overwrites in place. **A recurrence resolves to an UPDATE
|
|
170
|
+
that increments `seen_count` by 1 and refreshes `expires`** — that is what makes
|
|
171
|
+
recurrence countable and drives promotion. If a lesson you applied at the start
|
|
172
|
+
of the run worked (the failure did not recur), still write the UPDATE:
|
|
173
|
+
successful application is recurrence evidence.
|
|
174
|
+
|
|
175
|
+
The privacy pre-flight is never skipped, autonomous or not: a candidate lesson
|
|
176
|
+
containing a secret, token, credential, or PII is **dropped, not written**. The
|
|
177
|
+
bar is stricter for `repo::` writes — a repo scope is team-visible.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Promotion (fast → slow)
|
|
182
|
+
|
|
183
|
+
After a read or write, a lesson is **promotion-eligible** when either:
|
|
184
|
+
|
|
185
|
+
- `seen_count >= 3` — the same failure recurred across at least three runs, or
|
|
186
|
+
- it is tagged `status=structural` because it reflects a design gap, not a
|
|
187
|
+
one-off.
|
|
188
|
+
|
|
189
|
+
For an eligible lesson, **surface a one-line suggestion — never act silently**:
|
|
190
|
+
|
|
191
|
+
> Lesson "<title>" recurred N times. Promote it to a permanent guard in
|
|
192
|
+
> `<host>`? (edit the host's source / rules behind your normal review.)
|
|
193
|
+
|
|
194
|
+
The promotion target follows the lesson's scope: a `global` lesson hardens the
|
|
195
|
+
**host's own source** (every user of the host benefits); a `repo::` lesson
|
|
196
|
+
hardens the **repo's own rules / docs** (every teammate in that repo benefits).
|
|
197
|
+
Promotion is a normal, human-reviewed edit — LoreKit does not apply it. After a
|
|
198
|
+
successful promotion, write an UPDATE setting `status=promoted` so the lesson
|
|
199
|
+
stops re-suggesting and stands as an audit trail of why the rule exists.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Entrenchment guards (do not skip these)
|
|
204
|
+
|
|
205
|
+
The dominant risk of any reflective-memory loop is **self-reinforcing error**:
|
|
206
|
+
the host wrongly concludes "approach X always fails", stores it, avoids X
|
|
207
|
+
forever, and never gathers the evidence to overturn the false belief. These
|
|
208
|
+
guards are what make the loop safe:
|
|
209
|
+
|
|
210
|
+
1. **Lessons are advisory, never auto-applied to behavior.** A lesson biases a
|
|
211
|
+
run; it can never silently disable a gate, skip a step, or change a limit. The
|
|
212
|
+
only path from a lesson to changed behavior is the human-reviewed slow tier.
|
|
213
|
+
2. **Recurrence gates promotion, not a single run** (`seen_count >= 3`, or an
|
|
214
|
+
explicit `status=structural` marker in the lesson's `meta:` comment).
|
|
215
|
+
3. **Every lesson expires** (default ~90 days from last sighting; the read step
|
|
216
|
+
ignores expired lessons, so stale beliefs decay instead of entrenching).
|
|
217
|
+
4. **Contradiction is surfaced, not silently overwritten** — the dedup search
|
|
218
|
+
finds the prior lesson; a genuine reversal is a reviewed decision.
|
|
219
|
+
5. **The privacy pre-flight is never bypassed** — secrets / PII are dropped, not
|
|
220
|
+
stored.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Wiring checklist
|
|
225
|
+
|
|
226
|
+
To add a loop to a host called `<host>`:
|
|
227
|
+
|
|
228
|
+
- [ ] Pick the bucket: tag `loop::<host>-lessons`, key `<host>-lessons::<slug>`.
|
|
229
|
+
- [ ] Add the **read step** at the start of the host's run (narrow-to-broad
|
|
230
|
+
`memory.list` filtered by the tag; apply matches as considerations; skip
|
|
231
|
+
expired).
|
|
232
|
+
- [ ] Add the **write step** at the host's existing failure / end-of-run points
|
|
233
|
+
(classify scope, `memory.search` to dedup, `memory.write`). No new
|
|
234
|
+
reflection stage — hook the points the host already detects.
|
|
235
|
+
- [ ] Add the **promotion suggestion** when a read/written lesson hits
|
|
236
|
+
`seen_count >= 3` or `status=structural`.
|
|
237
|
+
- [ ] State the **entrenchment guards** so a future maintainer does not "optimize
|
|
238
|
+
them away".
|
|
239
|
+
- [ ] Confirm the loop **degrades silently** when `memory.*` is not connected.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Interactive setup
|
|
244
|
+
|
|
245
|
+
When a user asks to set up self-improving memory for a specific host, walk them
|
|
246
|
+
through it rather than dumping the whole recipe:
|
|
247
|
+
|
|
248
|
+
1. **Confirm it should have a loop** — run the "when to add a loop" test above.
|
|
249
|
+
If the host is a one-shot utility, an adversarial pass, or a secret-handler,
|
|
250
|
+
say so and stop.
|
|
251
|
+
2. **Name the bucket** — `loop::<host>-lessons` from the host's name.
|
|
252
|
+
3. **Locate the read and write points** — the start of a run, and the failure /
|
|
253
|
+
end-of-run points the host already has. Show the two `memory.*` snippets with
|
|
254
|
+
the bucket filled in.
|
|
255
|
+
4. **Write the guards down** in the host's own docs, so they survive future edits.
|
|
256
|
+
5. **Do a dry run** — trigger a failure, confirm a lesson is written to the right
|
|
257
|
+
scope with the right tag, then confirm the next run reads it back.
|
package/src/config.mjs
CHANGED
|
@@ -6,8 +6,17 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
|
|
7
7
|
// packages/cli/ — the installable package root (this file lives in src/).
|
|
8
8
|
export const PKG_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
9
|
-
export const SKILL_SOURCE = path.join(PKG_ROOT, 'skill', 'lorekit-memory');
|
|
10
9
|
export const SKILL_NAME = 'lorekit-memory';
|
|
10
|
+
export const SKILL_SOURCE = path.join(PKG_ROOT, 'skill', SKILL_NAME);
|
|
11
|
+
|
|
12
|
+
// Every skill the CLI ships. `lorekit-memory` is the operational read/write
|
|
13
|
+
// loop (kept first as the primary — `SKILL_NAME`/`SKILL_SOURCE` above alias it
|
|
14
|
+
// for back-compat); `lorekit-setup` is the authoring skill that wires a
|
|
15
|
+
// self-improvement loop into a host. install/uninstall/doctor iterate this list.
|
|
16
|
+
export const SKILLS = ['lorekit-memory', 'lorekit-setup'].map((name) => ({
|
|
17
|
+
name,
|
|
18
|
+
source: path.join(PKG_ROOT, 'skill', name),
|
|
19
|
+
}));
|
|
11
20
|
|
|
12
21
|
export function resolveProjectRoot(dir) {
|
|
13
22
|
return path.resolve(dir || process.cwd());
|
|
@@ -62,9 +71,9 @@ export function mcpConfigPath(root, scope = 'project') {
|
|
|
62
71
|
// Where the skill is scaffolded for a given scope:
|
|
63
72
|
// project → <root>/.claude/skills/… (this repo only)
|
|
64
73
|
// global → ~/.claude/skills/… (personal skills, all projects)
|
|
65
|
-
export function skillInstallDir(root, scope = 'project') {
|
|
74
|
+
export function skillInstallDir(root, scope = 'project', name = SKILL_NAME) {
|
|
66
75
|
const base = scope === 'global' ? homeDir() : root;
|
|
67
|
-
return path.join(base, '.claude', 'skills',
|
|
76
|
+
return path.join(base, '.claude', 'skills', name);
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
// Claude Code settings file that holds the hooks for a given scope:
|
|
@@ -231,10 +240,10 @@ export function readLorekitServer(root) {
|
|
|
231
240
|
// server / hook / setting intact, so uninstalling never damages a shared
|
|
232
241
|
// ~/.claude.json or settings.json.
|
|
233
242
|
|
|
234
|
-
// Delete
|
|
235
|
-
// .claude/skills
|
|
236
|
-
export function removeSkill(root, scope = 'project') {
|
|
237
|
-
const dest = skillInstallDir(root, scope);
|
|
243
|
+
// Delete a scaffolded skill directory. We own the whole
|
|
244
|
+
// .claude/skills/<name> tree for each skill we ship, so a recursive remove is safe.
|
|
245
|
+
export function removeSkill(root, scope = 'project', name = SKILL_NAME) {
|
|
246
|
+
const dest = skillInstallDir(root, scope, name);
|
|
238
247
|
const removed = fs.existsSync(dest);
|
|
239
248
|
if (removed) fs.rmSync(dest, { recursive: true, force: true });
|
|
240
249
|
return { dest, removed };
|
package/src/core/lessons.mjs
CHANGED
|
@@ -3,23 +3,52 @@
|
|
|
3
3
|
// Storage is reached through the resolved store (local | remote), never a
|
|
4
4
|
// backend directly, so the same read path serves every mode.
|
|
5
5
|
import { deriveScope } from '../scope.mjs';
|
|
6
|
+
// The precedence merge and the literal substring matcher come from the
|
|
7
|
+
// dependency-free `lessons-pure.mjs` — the SAME primitives `tree` and `search`
|
|
8
|
+
// use, so the hook can't drift from them, and the hot path never pulls in the
|
|
9
|
+
// `lessons-view.mjs` render/`util` stack.
|
|
10
|
+
import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
|
|
6
11
|
|
|
7
12
|
const MAX_LESSONS = 15;
|
|
13
|
+
// Cap on lessons injected on a failure — a small, focused "you've seen this
|
|
14
|
+
// before" set, never the whole applicable corpus.
|
|
15
|
+
const MAX_RELEVANT = 3;
|
|
16
|
+
// Cap on the terms distilled from a failure — bounds the relevance scan so a
|
|
17
|
+
// huge error blob can never turn into an unbounded match loop.
|
|
18
|
+
const MAX_TERMS = 12;
|
|
19
|
+
// Terms shorter than this are dropped: too generic to make a match meaningful.
|
|
20
|
+
const MIN_TERM_LEN = 4;
|
|
21
|
+
// Cap on how much failure text is scanned for terms. A tool can dump a
|
|
22
|
+
// multi-megabyte stderr/stdout; the salient error words are always near the
|
|
23
|
+
// front, so we bound the input BEFORE lowercasing/splitting it — otherwise a
|
|
24
|
+
// giant blob would materialise a giant token array (a CPU/memory spike) even
|
|
25
|
+
// though the term COUNT is capped. Generous enough to never clip a real error.
|
|
26
|
+
const MAX_SCAN_CHARS = 4096;
|
|
8
27
|
|
|
9
|
-
// Read lessons narrow-to-broad through the store and
|
|
10
|
-
//
|
|
28
|
+
// Read lessons narrow-to-broad through the store and resolve cross-scope
|
|
29
|
+
// precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
|
|
30
|
+
// more-specific-wins merge `tree` renders) — so the hook and `tree` provably
|
|
31
|
+
// can't drift. Any per-scope failure is skipped (memory is best-effort).
|
|
11
32
|
export async function fetchLessons(store, cwd) {
|
|
12
33
|
const scope = deriveScope(cwd);
|
|
13
|
-
const
|
|
34
|
+
const groups = [];
|
|
14
35
|
for (const s of scope.readOrder) {
|
|
15
36
|
const res = await store.list({ scope: s, limit: 25 });
|
|
16
|
-
if (!res || !res.ok) continue;
|
|
17
|
-
const entries = Array.isArray(res.entries) ? res.entries : []
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
37
|
+
if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
|
|
38
|
+
const entries = (Array.isArray(res.entries) ? res.entries : [])
|
|
39
|
+
.filter((e) => e && e.key)
|
|
40
|
+
.map((e) => ({ ...e, scope: s }));
|
|
41
|
+
groups.push({ scope: s, error: null, entries });
|
|
21
42
|
}
|
|
22
|
-
|
|
43
|
+
// First value per key wins (most-specific scope, since `readOrder` is
|
|
44
|
+
// narrow→broad) — exactly what the old inline `byKey` merge did, now via the
|
|
45
|
+
// one shared resolver. The winners, in group order, are the injected set.
|
|
46
|
+
const { groups: resolved } = resolvePrecedence({ groups });
|
|
47
|
+
const lessons = [];
|
|
48
|
+
for (const g of resolved) {
|
|
49
|
+
for (const e of g.entries) if (e.winning) lessons.push(e);
|
|
50
|
+
}
|
|
51
|
+
return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
|
|
23
52
|
}
|
|
24
53
|
|
|
25
54
|
// Render lessons as a compact markdown block, or null when there are none.
|
|
@@ -37,6 +66,60 @@ export function formatLessons(lessons, scope) {
|
|
|
37
66
|
return `${header}\n${body}`;
|
|
38
67
|
}
|
|
39
68
|
|
|
69
|
+
// Distil a small set of significant, lowercased search TERMS from a tool
|
|
70
|
+
// failure — the tool name plus the salient words of its error text — used to
|
|
71
|
+
// look up lessons that might already cover this failure. Pure and total: any
|
|
72
|
+
// shape of `toolResponse` (object, string, null, malformed) is handled without
|
|
73
|
+
// throwing. Terms are de-duplicated, stopword- and length-filtered so a match
|
|
74
|
+
// stays meaningful, and the count is capped (`MAX_TERMS`) so a huge error blob
|
|
75
|
+
// can't blow up the downstream scan.
|
|
76
|
+
export function failureQuery(toolName, toolResponse) {
|
|
77
|
+
const text = `${toolName ? String(toolName) : ''} ${errorText(toolResponse)}`.slice(0, MAX_SCAN_CHARS);
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const terms = [];
|
|
80
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
81
|
+
if (raw.length < MIN_TERM_LEN || STOPWORDS.has(raw) || seen.has(raw)) continue;
|
|
82
|
+
seen.add(raw);
|
|
83
|
+
terms.push(raw);
|
|
84
|
+
if (terms.length >= MAX_TERMS) break;
|
|
85
|
+
}
|
|
86
|
+
return terms;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lessons whose key OR value literally contains ANY of the failure `terms`
|
|
90
|
+
// (case-insensitive, via the shared `matchesQuery` — never a regex), capped at
|
|
91
|
+
// `cap`. Pure and best-effort: no terms or no lessons → empty (the caller then
|
|
92
|
+
// falls back to the write-nudge alone). Preserves `lessons` order, so the
|
|
93
|
+
// most-specific scope's relevant lesson surfaces first.
|
|
94
|
+
export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
|
|
95
|
+
if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const l of lessons) {
|
|
100
|
+
if (terms.some((t) => matchesQuery(l, t))) out.push(l);
|
|
101
|
+
if (out.length >= cap) break;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Render the relevant-lessons block injected alongside the failure nudge, or
|
|
107
|
+
// null when nothing matched. Framed as prior art, not a directive — same
|
|
108
|
+
// "considerations, not rules" stance as `formatLessons`.
|
|
109
|
+
export function formatRelevantLessons(lessons) {
|
|
110
|
+
if (!lessons || lessons.length === 0) return null;
|
|
111
|
+
const header =
|
|
112
|
+
`LoreKit — you've hit something like this before. ${lessons.length} related ` +
|
|
113
|
+
'lesson(s) (considerations, not rules; trust the current code if they conflict):';
|
|
114
|
+
const body = lessons
|
|
115
|
+
.map((l) => {
|
|
116
|
+
const first = String(l.value || '').split('\n')[0].slice(0, 300);
|
|
117
|
+
return `- (${l.scope}) ${l.key}: ${first}`;
|
|
118
|
+
})
|
|
119
|
+
.join('\n');
|
|
120
|
+
return `${header}\n${body}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
40
123
|
// The retrospective nudge emitted at end-of-turn (one-shot per session).
|
|
41
124
|
export function retrospectiveNudge(scope) {
|
|
42
125
|
const writeScope = scope.repoScope || 'global';
|
|
@@ -58,3 +141,64 @@ export function failureNudge(toolName, scope) {
|
|
|
58
141
|
`lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
|
|
59
142
|
);
|
|
60
143
|
}
|
|
144
|
+
|
|
145
|
+
// Pull the human-readable error text out of a tool_response of any shape. Total:
|
|
146
|
+
// null/primitive/string/object all yield a (possibly empty) string, never a
|
|
147
|
+
// throw — the relevance lookup is best-effort and must not break the host.
|
|
148
|
+
function errorText(response) {
|
|
149
|
+
if (response == null) return '';
|
|
150
|
+
if (typeof response === 'string') return response;
|
|
151
|
+
if (typeof response !== 'object') return String(response);
|
|
152
|
+
const parts = [];
|
|
153
|
+
for (const f of TEXT_FIELDS) {
|
|
154
|
+
const v = response[f];
|
|
155
|
+
if (typeof v === 'string') parts.push(v);
|
|
156
|
+
else if (v && typeof v === 'object') parts.push(safeStringify(v));
|
|
157
|
+
}
|
|
158
|
+
return parts.length ? parts.join(' ') : safeStringify(response);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeStringify(v) {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.stringify(v) || '';
|
|
164
|
+
} catch {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The tool_response fields that commonly carry error text, across frameworks.
|
|
170
|
+
const TEXT_FIELDS = [
|
|
171
|
+
'stderr',
|
|
172
|
+
'error',
|
|
173
|
+
'message',
|
|
174
|
+
'stdout',
|
|
175
|
+
'output',
|
|
176
|
+
'content',
|
|
177
|
+
'reason',
|
|
178
|
+
'detail',
|
|
179
|
+
'details',
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// Generic words dropped from a failure query: too common to make a lesson match
|
|
183
|
+
// meaningful (they'd match almost anything). Kept small and hand-picked.
|
|
184
|
+
const STOPWORDS = new Set([
|
|
185
|
+
'error',
|
|
186
|
+
'errors',
|
|
187
|
+
'failed',
|
|
188
|
+
'failure',
|
|
189
|
+
'tool',
|
|
190
|
+
'call',
|
|
191
|
+
'code',
|
|
192
|
+
'exit',
|
|
193
|
+
'with',
|
|
194
|
+
'this',
|
|
195
|
+
'that',
|
|
196
|
+
'from',
|
|
197
|
+
'null',
|
|
198
|
+
'true',
|
|
199
|
+
'false',
|
|
200
|
+
'undefined',
|
|
201
|
+
'command',
|
|
202
|
+
'response',
|
|
203
|
+
'status',
|
|
204
|
+
]);
|
package/src/doctor.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
SKILLS,
|
|
9
9
|
resolveProjectRoot,
|
|
10
10
|
skillInstallDir,
|
|
11
11
|
readLorekitServer,
|
|
@@ -42,18 +42,24 @@ export async function doctor(args) {
|
|
|
42
42
|
`v${process.versions.node}${major < 18 ? ' — need v18+ for fetch' : ''}`,
|
|
43
43
|
);
|
|
44
44
|
|
|
45
|
-
// 2.
|
|
46
|
-
// locations. `lorekit install --global` writes the
|
|
47
|
-
// repo, so a project-only check reports a healthy
|
|
48
|
-
// found" (exactly the false FAIL a --global setup would
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
// 2. Skills installed — check every skill the CLI ships, in BOTH the project
|
|
46
|
+
// and the global (~/.claude) locations. `lorekit install --global` writes the
|
|
47
|
+
// skill under home, not the repo, so a project-only check reports a healthy
|
|
48
|
+
// global install as "not found" (exactly the false FAIL a --global setup would
|
|
49
|
+
// hit).
|
|
50
|
+
for (const skill of SKILLS) {
|
|
51
|
+
const skillMd = [
|
|
52
|
+
skillInstallDir(root, 'project', skill.name),
|
|
53
|
+
skillInstallDir(root, 'global', skill.name),
|
|
54
|
+
]
|
|
55
|
+
.map((dir) => path.join(dir, 'SKILL.md'))
|
|
56
|
+
.find((p) => fs.existsSync(p));
|
|
57
|
+
if (skillMd) {
|
|
58
|
+
const rel = path.relative(root, skillMd);
|
|
59
|
+
record('pass', `skill ${skill.name}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
|
|
60
|
+
} else {
|
|
61
|
+
record('fail', `skill ${skill.name}`, 'not found — run `lorekit install`');
|
|
62
|
+
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
// 3. Resolved control model — which mode, and who decided it.
|
package/src/hook.mjs
CHANGED
|
@@ -7,7 +7,15 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
7
7
|
import { deriveScope } from './scope.mjs';
|
|
8
8
|
import { loadControl } from './control.mjs';
|
|
9
9
|
import { createStore } from './store/index.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
fetchLessons,
|
|
12
|
+
formatLessons,
|
|
13
|
+
retrospectiveNudge,
|
|
14
|
+
failureNudge,
|
|
15
|
+
failureQuery,
|
|
16
|
+
relevantLessons,
|
|
17
|
+
formatRelevantLessons,
|
|
18
|
+
} from './core/lessons.mjs';
|
|
11
19
|
import { isFailure } from './core/failure.mjs';
|
|
12
20
|
import { firstTimeThisSession } from './core/state.mjs';
|
|
13
21
|
import { recordFixture } from './core/record.mjs';
|
|
@@ -92,7 +100,22 @@ async function run(args) {
|
|
|
92
100
|
const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
|
|
93
101
|
if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
|
|
94
102
|
if (!firstTimeThisSession(parsed.sessionId, 'failure')) return 0;
|
|
95
|
-
|
|
103
|
+
// Best-effort: surface any existing lessons that look relevant to THIS
|
|
104
|
+
// failure ("you've hit this before"), then the write-nudge. A missing /
|
|
105
|
+
// unusable store, or no match, silently falls back to the nudge alone.
|
|
106
|
+
let relevant = null;
|
|
107
|
+
try {
|
|
108
|
+
const store = createStore(control);
|
|
109
|
+
if (store) {
|
|
110
|
+
const { lessons } = await fetchLessons(store, root);
|
|
111
|
+
const terms = failureQuery(parsed.toolName, parsed.toolResponse);
|
|
112
|
+
relevant = formatRelevantLessons(relevantLessons(lessons, terms));
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
relevant = null; // never let a lesson lookup break the failure nudge
|
|
116
|
+
}
|
|
117
|
+
const nudge = failureNudge(parsed.toolName, scope);
|
|
118
|
+
emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
|
|
96
119
|
return 0;
|
|
97
120
|
}
|
|
98
121
|
|
package/src/install.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
// `lorekit install` — scaffold the
|
|
1
|
+
// `lorekit install` — scaffold the skills and wire the MCP server.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import readline from 'node:readline';
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
SKILL_NAME,
|
|
7
|
+
SKILLS,
|
|
9
8
|
resolveProjectRoot,
|
|
10
9
|
skillInstallDir,
|
|
11
10
|
copyDir,
|
|
@@ -73,10 +72,13 @@ export async function install(args) {
|
|
|
73
72
|
token = token || null;
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
// 3. Install the skill files.
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
// 3. Install the skill files — every skill the CLI ships.
|
|
76
|
+
const skillResults = SKILLS.map((skill) => {
|
|
77
|
+
const dest = skillInstallDir(root, scope, skill.name);
|
|
78
|
+
const existed = fs.existsSync(path.join(dest, 'SKILL.md'));
|
|
79
|
+
const written = copyDir(skill.source, dest, { force: Boolean(args.force) });
|
|
80
|
+
return { name: skill.name, dest, existed, written };
|
|
81
|
+
});
|
|
80
82
|
|
|
81
83
|
// 4. Wire the MCP config for the chosen scope.
|
|
82
84
|
const remoteUrl = buildRemoteUrl(endpoint, token);
|
|
@@ -100,12 +102,14 @@ export async function install(args) {
|
|
|
100
102
|
|
|
101
103
|
// 5. Report.
|
|
102
104
|
heading('Done');
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
for (const s of skillResults) {
|
|
106
|
+
const skillState = !s.existed
|
|
107
|
+
? 'installed'
|
|
108
|
+
: s.written > 0
|
|
109
|
+
? `updated (${s.written} file(s) written)`
|
|
110
|
+
: 'unchanged — pass --force to overwrite';
|
|
111
|
+
status(s.existed && s.written === 0 ? 'info' : 'pass', `skill ${s.name}`, `${skillState} → ${display(s.dest)}`);
|
|
112
|
+
}
|
|
109
113
|
status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
|
|
110
114
|
|
|
111
115
|
if (!wireHooks) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure, DEPENDENCY-FREE lesson primitives shared by the hook engine
|
|
2
|
+
// (`core/lessons.mjs`, the hot path) and the read-command view layer
|
|
3
|
+
// (`lessons-view.mjs`, which re-exports these). Kept in its own module so the
|
|
4
|
+
// hook can import the exact precedence + match logic the read commands use —
|
|
5
|
+
// one source of truth, no drift — WITHOUT dragging in the rendering/`util`
|
|
6
|
+
// stack (`heading`/`log`/`c`, plus the lint/dedupe/diff cores) that the rest of
|
|
7
|
+
// `lessons-view.mjs` carries. Zero imports on purpose.
|
|
8
|
+
|
|
9
|
+
// ── cross-scope precedence resolution (the `tree` + hook merge core) ──────────
|
|
10
|
+
|
|
11
|
+
// Given per-scope groups in RESOLUTION ORDER — most-specific first, exactly the
|
|
12
|
+
// order `deriveScope().readOrder` produces — compute which scope's lesson WINS
|
|
13
|
+
// each key and which are shadowed. This is the hook engine's merge: it iterates
|
|
14
|
+
// the scopes narrow-to-broad and keeps the FIRST value seen per key (`if
|
|
15
|
+
// (!winnerScopeByKey.has(key)) set`), so a more-specific scope shadows a broader
|
|
16
|
+
// scope's same-key lesson. Returns the same groups with every entry tagged
|
|
17
|
+
// `{ winning, shadowedBy }` (`shadowedBy` = the scope that won the key, or null
|
|
18
|
+
// for a winner), plus the resolved `winners` list (`{ scope, key }`, one per
|
|
19
|
+
// key) and winning/shadowed counts. A per-scope read error is passed through
|
|
20
|
+
// untouched (empty entries), so one unreadable scope never derails resolution.
|
|
21
|
+
// Pure — consumed by both `tree` (display) and `fetchLessons` (injection).
|
|
22
|
+
export function resolvePrecedence({ groups = [] } = {}) {
|
|
23
|
+
const winnerScopeByKey = new Map(); // key → the scope that first claimed it
|
|
24
|
+
const outGroups = [];
|
|
25
|
+
let winningTotal = 0;
|
|
26
|
+
let shadowedTotal = 0;
|
|
27
|
+
for (const g of groups) {
|
|
28
|
+
if (g.error) {
|
|
29
|
+
outGroups.push({ scope: g.scope, error: g.error, entries: [] });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const e of g.entries || []) {
|
|
34
|
+
const prior = winnerScopeByKey.get(e.key);
|
|
35
|
+
if (prior === undefined) {
|
|
36
|
+
winnerScopeByKey.set(e.key, g.scope);
|
|
37
|
+
entries.push({ ...e, winning: true, shadowedBy: null });
|
|
38
|
+
winningTotal += 1;
|
|
39
|
+
} else {
|
|
40
|
+
entries.push({ ...e, winning: false, shadowedBy: prior });
|
|
41
|
+
shadowedTotal += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
outGroups.push({ scope: g.scope, error: null, entries });
|
|
45
|
+
}
|
|
46
|
+
const winners = [...winnerScopeByKey.entries()].map(([key, scope]) => ({ scope, key }));
|
|
47
|
+
return { groups: outGroups, winners, winningTotal, shadowedTotal };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── literal, case-insensitive substring matching (the `search` + hook core) ───
|
|
51
|
+
|
|
52
|
+
// Case-insensitive, LITERAL substring match of `query` against a normalized
|
|
53
|
+
// entry's key OR value — the `search` command's matcher, and the hook's
|
|
54
|
+
// failure-relevance matcher. Deliberately a plain `String.includes` (never
|
|
55
|
+
// `new RegExp(query)`) so a query full of regex metacharacters like `a.*(b)`
|
|
56
|
+
// matches those characters verbatim, never as a pattern. An empty query matches
|
|
57
|
+
// everything (the command guards emptiness as a usage error before ever calling
|
|
58
|
+
// this). Pure — trivially unit-testable.
|
|
59
|
+
export function matchesQuery(entry, query) {
|
|
60
|
+
const needle = String(query == null ? '' : query).toLowerCase();
|
|
61
|
+
if (!needle) return true;
|
|
62
|
+
const key = String(entry?.key ?? '').toLowerCase();
|
|
63
|
+
const value = String(entry?.value ?? '').toLowerCase();
|
|
64
|
+
return key.includes(needle) || value.includes(needle);
|
|
65
|
+
}
|
package/src/lessons-view.mjs
CHANGED
|
Binary file
|
package/src/scope.mjs
CHANGED
|
@@ -44,7 +44,11 @@ export function deriveScope(cwd = process.cwd()) {
|
|
|
44
44
|
: null;
|
|
45
45
|
const projectScope = `project::${projectName}`;
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Injection / resolution order, most-specific → broadest. Matches the read
|
|
48
|
+
// commands' `scopeList` exactly (`lessons-view.mjs`) — project is FIRST, so a
|
|
49
|
+
// project-scoped lesson wins and IS injected. De-duplicated (a project whose
|
|
50
|
+
// basename collides with a scope, or a repo with no branch, never repeats).
|
|
51
|
+
const readOrder = [...new Set([projectScope, branchScope, repoScope, 'global'].filter(Boolean))];
|
|
48
52
|
|
|
49
53
|
return {
|
|
50
54
|
ownerRepo,
|
package/src/store/local.mjs
CHANGED
|
@@ -316,7 +316,7 @@ class TwoTierStore {
|
|
|
316
316
|
const projRes = this.projectActive()
|
|
317
317
|
? await this.project.search({ q, scopes, tags })
|
|
318
318
|
: { entries: [] };
|
|
319
|
-
const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => `${e.scope}
|
|
319
|
+
const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => `${e.scope}\x00${e.key}`);
|
|
320
320
|
return { ok: true, entries: merged };
|
|
321
321
|
}
|
|
322
322
|
|
package/src/tree.mjs
CHANGED
|
@@ -5,17 +5,18 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Precedence is not an assumption — it mirrors the hook engine exactly. The
|
|
7
7
|
// SessionStart hook (`core/lessons.mjs → fetchLessons`) reads the scopes in
|
|
8
|
-
// `deriveScope().readOrder` (branch → repo → global, most-specific
|
|
9
|
-
// keeps the FIRST value seen per key, so a more-specific scope
|
|
10
|
-
// scope's same-key lesson. `tree` resolves over that same
|
|
11
|
-
// the pure `resolvePrecedence`, so it shows the same
|
|
12
|
-
// is injected with (the hook additionally caps the
|
|
13
|
-
// `tree` is uncapped, so a large workspace may list
|
|
14
|
-
// injects).
|
|
8
|
+
// `deriveScope().readOrder` (project → branch → repo → global, most-specific
|
|
9
|
+
// first) and keeps the FIRST value seen per key, so a more-specific scope
|
|
10
|
+
// shadows a broader scope's same-key lesson. `tree` resolves over that same
|
|
11
|
+
// `readOrder` set via the pure `resolvePrecedence`, so it shows the same
|
|
12
|
+
// resolution order the agent is injected with (the hook additionally caps the
|
|
13
|
+
// injected set at MAX_LESSONS; `tree` is uncapped, so a large workspace may list
|
|
14
|
+
// more winners than the hook injects).
|
|
15
15
|
//
|
|
16
|
-
// NOTE on scope coverage: `readOrder` is the injected set
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
|
|
17
|
+
// PR it INCLUDES `project::` (project is the most-specific scope and now wins /
|
|
18
|
+
// is injected), so `tree` shows it too — the ordering is now unified across the
|
|
19
|
+
// hook, `tree`, and every read command's `scopeList`. Both stores are resolved
|
|
19
20
|
// independently (precedence is per-store — the hook reads one resolved store),
|
|
20
21
|
// in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
|
|
21
22
|
// `traceCommand` by the bin.
|
|
@@ -24,7 +25,8 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
24
25
|
import { deriveScope } from './scope.mjs';
|
|
25
26
|
import { resolveDenies } from './control.mjs';
|
|
26
27
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
27
|
-
import {
|
|
28
|
+
import { resolvePrecedence } from './lessons-pure.mjs';
|
|
29
|
+
import { gather, preview, shortDate } from './lessons-view.mjs';
|
|
28
30
|
import { log, heading, status, c } from './util.mjs';
|
|
29
31
|
|
|
30
32
|
export async function tree(args) {
|
package/src/uninstall.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import readline from 'node:readline';
|
|
6
6
|
import process from 'node:process';
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
SKILLS,
|
|
9
9
|
resolveProjectRoot,
|
|
10
10
|
removeSkill,
|
|
11
11
|
removeMcpServer,
|
|
@@ -51,7 +51,10 @@ export async function uninstall(args) {
|
|
|
51
51
|
// install — never clobber what we can't understand), so we catch and report it
|
|
52
52
|
// cleanly, leaving that file byte-for-byte untouched. Atomic writes
|
|
53
53
|
// (writeFileAtomic) guarantee a config can't be half-written even on a crash.
|
|
54
|
-
const
|
|
54
|
+
const skillSteps = SKILLS.map((skill) => ({
|
|
55
|
+
name: skill.name,
|
|
56
|
+
step: attempt(() => removeSkill(root, scope, skill.name)),
|
|
57
|
+
}));
|
|
55
58
|
const mcp = attempt(() => removeMcpServer(root, scope));
|
|
56
59
|
const hooks = attempt(() => removeClaudeHooks(root, scope));
|
|
57
60
|
|
|
@@ -61,10 +64,12 @@ export async function uninstall(args) {
|
|
|
61
64
|
const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
|
|
62
65
|
|
|
63
66
|
heading('Done');
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
for (const { name, step } of skillSteps) {
|
|
68
|
+
report(step, `skill ${name}`, {
|
|
69
|
+
done: (r) => `removed → ${display(r.dest)}`,
|
|
70
|
+
noop: 'not installed — nothing to remove',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
68
73
|
report(mcp, mcpLabel, {
|
|
69
74
|
done: (r) => `lorekit server removed → ${display(r.file)}`,
|
|
70
75
|
noop: 'no lorekit server entry — nothing to remove',
|
|
@@ -74,8 +79,10 @@ export async function uninstall(args) {
|
|
|
74
79
|
noop: 'no lorekit hooks — nothing to remove',
|
|
75
80
|
});
|
|
76
81
|
|
|
77
|
-
const
|
|
78
|
-
const
|
|
82
|
+
const skillStepList = skillSteps.map((s) => s.step);
|
|
83
|
+
const failed = [...skillStepList, mcp, hooks].some((s) => !s.ok);
|
|
84
|
+
const any =
|
|
85
|
+
(skillStepList.some((s) => s.result?.removed) || mcp.result?.removed || hooks.result?.removed) && true;
|
|
79
86
|
|
|
80
87
|
if (failed) {
|
|
81
88
|
log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);
|