@lorekit/cli 1.69.0 → 1.71.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.
@@ -0,0 +1,154 @@
1
+ // SessionStart skill-update drift nudge: a terse, throttled line telling the
2
+ // agent "your skills are stale, run `lorekit update`" — computed entirely
3
+ // offline (see ../shared/skill-versions.mjs) and gated by `updates.notify`
4
+ // (../shared/control.mjs).
5
+ //
6
+ // Throttle state lives at `$LOREKIT_HOME/update-state.json`, following the
7
+ // exact precedent `telemetry/telemetry-identity.mjs` sets for a small,
8
+ // user-owned JSON file under the home tier: TOTAL reads (a missing/corrupt
9
+ // file degrades to `{}`, never throws), atomic writes via
10
+ // `shared/config.mjs`'s `writeFileAtomic`, and the file is written ONLY when
11
+ // there is something to record — never merely because this ran.
12
+ //
13
+ // The nudge fires at most once per NEW shipped-version signature, plus a
14
+ // 7-day cooldown on repeating that same signature. `updates.notify: off`
15
+ // short-circuits before any filesystem check runs, so an opted-out user's
16
+ // disk is never touched by this module at all.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+ import { homeRoot } from '../shared/control.mjs';
21
+ import { writeFileAtomic } from '../shared/config.mjs';
22
+ import { checkSkillVersions, skillsNeedingUpdate } from '../shared/skill-versions.mjs';
23
+
24
+ const STATE_FILE = 'update-state.json';
25
+ const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
26
+
27
+ /** `$LOREKIT_HOME/update-state.json`, default `~/.lorekit/update-state.json`. */
28
+ export function updateStatePath(env = process.env) {
29
+ return path.join(homeRoot(env), STATE_FILE);
30
+ }
31
+
32
+ /**
33
+ * Read the stored throttle state, or `{}`. TOTAL — a missing file, an
34
+ * unreadable one, a corrupt body, or a body that parses but isn't the right
35
+ * shape all yield `{}`, exactly like `telemetry-identity.mjs`'s `readIdentity`.
36
+ */
37
+ export function readUpdateState(file = updateStatePath()) {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
40
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
41
+ const out = {};
42
+ if (typeof parsed.lastNotifiedVersion === 'string' && parsed.lastNotifiedVersion) {
43
+ out.lastNotifiedVersion = parsed.lastNotifiedVersion;
44
+ }
45
+ if (typeof parsed.lastNotifiedAt === 'number' && Number.isFinite(parsed.lastNotifiedAt)) {
46
+ out.lastNotifiedAt = parsed.lastNotifiedAt;
47
+ }
48
+ return out;
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ // Persist the throttle state, creating the home directory if needed. Returns
55
+ // true on success, false on any failure (unwritable home, full disk) — a
56
+ // write failure must not surface as an error, since a hook this fires from
57
+ // must never break the host on a filesystem hiccup.
58
+ function writeUpdateState(state, file = updateStatePath()) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A stable signature naming exactly which skills need an update and at which
70
+ * shipped version, e.g. `"lorekit-memory@1.2.0,lorekit-setup@1.1.0"`. Sorted
71
+ * by skill name so the signature is deterministic regardless of scan order.
72
+ *
73
+ * This — not a bare boolean — is what the throttle compares against: a NEW
74
+ * release (a shipped version bump) or a previously-current skill drifting
75
+ * produces a DIFFERENT signature, which re-qualifies for a nudge even inside
76
+ * the 7-day cooldown on the old one. `""` means nothing needs an update.
77
+ */
78
+ export function driftSignature(results) {
79
+ const bySkill = new Map();
80
+ for (const r of skillsNeedingUpdate(results)) {
81
+ if (!r.shipped || bySkill.has(r.name)) continue;
82
+ bySkill.set(r.name, r.shipped);
83
+ }
84
+ return [...bySkill.entries()]
85
+ .sort(([a], [b]) => a.localeCompare(b))
86
+ .map(([name, shipped]) => `${name}@${shipped}`)
87
+ .join(',');
88
+ }
89
+
90
+ /**
91
+ * Render the one-line nudge, or `null` when nothing needs an update. De-duped
92
+ * by skill name (the same `name -> shipped` Map `driftSignature` builds) —
93
+ * `skillsNeedingUpdate` returns one row per (skill, scope), and without the
94
+ * dedupe a skill outdated in BOTH the project and global install would count
95
+ * itself twice in the "(+N more)" tail. An `unknown` row (no readable
96
+ * installed version — the drift signature counts it too, see
97
+ * `driftSignature`) still gets named, just without a "vX →" on its left side,
98
+ * so the nudge is never silently dropped for the one state that most needs a
99
+ * "go look" — a legacy install with no parseable version at all.
100
+ */
101
+ export function formatUpdateNudge(results) {
102
+ const bySkill = new Map();
103
+ for (const r of skillsNeedingUpdate(results)) {
104
+ if (!r.shipped || bySkill.has(r.name)) continue;
105
+ bySkill.set(r.name, r);
106
+ }
107
+ const needing = [...bySkill.values()];
108
+ if (needing.length === 0) return null;
109
+ const [first, ...rest] = needing;
110
+ const extra = rest.length > 0 ? ` (+${rest.length} more)` : '';
111
+ const headline = first.installed
112
+ ? `${first.name} v${first.installed} → v${first.shipped}`
113
+ : `${first.name} → v${first.shipped}`;
114
+ return (
115
+ `LoreKit skills are outdated (${headline}${extra}). ` +
116
+ 'Run `lorekit update` to refresh.'
117
+ );
118
+ }
119
+
120
+ /**
121
+ * The full resolve: should a SessionStart nudge fire right now, and if so,
122
+ * record the throttle state and return the text? `null` means stay silent —
123
+ * `notify: off`, nothing outdated, or the same signature is still inside its
124
+ * 7-day cooldown.
125
+ *
126
+ * TOTAL and side-effect-light: nothing is read or written when `notify` is
127
+ * `off`, and the state file is written ONLY on the turn that actually emits a
128
+ * nudge — never merely because this ran. Any unexpected error (a throwing
129
+ * filesystem call the individual helpers didn't already swallow) is caught
130
+ * here too, so a caller never needs its own try/catch to stay safe.
131
+ */
132
+ export function resolveUpdateNudge(root, { notify = 'auto', now = Date.now(), env = process.env } = {}) {
133
+ if (notify === 'off') return null;
134
+ try {
135
+ const results = checkSkillVersions(root);
136
+ const signature = driftSignature(results);
137
+ if (!signature) return null;
138
+
139
+ const file = updateStatePath(env);
140
+ const state = readUpdateState(file);
141
+ const sameSignature = state.lastNotifiedVersion === signature;
142
+ const withinCooldown =
143
+ typeof state.lastNotifiedAt === 'number' && now - state.lastNotifiedAt < COOLDOWN_MS;
144
+ if (sameSignature && withinCooldown) return null;
145
+
146
+ const text = formatUpdateNudge(results);
147
+ if (!text) return null;
148
+
149
+ writeUpdateState({ lastNotifiedVersion: signature, lastNotifiedAt: now }, file);
150
+ return text;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
@@ -43,6 +43,7 @@ const FLAG = {
43
43
  force: { desc: 'Overwrite / hard-delete' },
44
44
  deep: { desc: 'Do a write→read→delete round-trip' },
45
45
  telemetry: { desc: 'Verify the OTLP export credential works' },
46
+ check: { desc: 'Report drift without writing anything' },
46
47
  json: { desc: 'Machine-readable output' },
47
48
  scope: { desc: 'Restrict to / name a scope', arg: 'scope', complete: 'scope' },
48
49
  key: { desc: 'Name the key explicitly', arg: 'key' },
@@ -107,6 +108,8 @@ const COMMANDS = [
107
108
  flags: ['dir', 'project', 'global', 'yes'] },
108
109
  { name: 'doctor', summary: 'Verify the install, connectivity, token, scope',
109
110
  flags: ['dir', 'mode', 'endpoint', 'token', 'store', 'deep', 'telemetry'] },
111
+ { name: 'update', summary: 'Offline refresh of the bundled skills to the shipped version',
112
+ flags: ['dir', 'project', 'global', 'check'] },
110
113
  { name: 'list', summary: 'List memories for the current directory', aliases: ['ls'],
111
114
  flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] },
112
115
  { name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'],
@@ -61,6 +61,21 @@ export function normalizeStopMode(v) {
61
61
  return null;
62
62
  }
63
63
 
64
+ // `updates.notify` — whether the SessionStart hook may append a terse
65
+ // skill-update nudge when an installed skill has drifted behind the version
66
+ // this CLI ships. `auto` (default) | `off`. Same forgiving-vocabulary /
67
+ // repo-wins model as `hooks.stop`: a plain boolean, `false`/`none`/`disabled`
68
+ // all mean `off`, so a hand-edited JSON file reads naturally either way.
69
+ export const UPDATE_NOTIFY_MODES = ['auto', 'off'];
70
+ export function normalizeUpdateNotifyMode(v) {
71
+ if (typeof v === 'boolean') return v ? 'auto' : 'off';
72
+ if (typeof v !== 'string') return null;
73
+ const s = v.trim().toLowerCase();
74
+ if (['off', 'none', 'false', 'disabled', 'never', 'no'].includes(s)) return 'off';
75
+ if (['auto', 'on', 'true', 'enabled', 'always', 'yes'].includes(s)) return 'auto';
76
+ return null;
77
+ }
78
+
64
79
  // `hooks.userPrompt` — the per-turn relevance pull, on or off.
65
80
  //
66
81
  // A BOOLEAN, not a mode, and that is a deliberate limit on the surface. The
@@ -454,6 +469,12 @@ export function resolveControl({
454
469
  normalizeUserPromptMode(userConfig['hooks.sessionStart.branchHint']) ||
455
470
  'on';
456
471
 
472
+ // `updates.notify` — repo layer wins over user layer, default `auto`.
473
+ const updatesNotify =
474
+ normalizeUpdateNotifyMode(repoConfig['updates.notify']) ||
475
+ normalizeUpdateNotifyMode(userConfig['updates.notify']) ||
476
+ 'auto';
477
+
457
478
  // `hooks.adapter` — repo layer wins over user layer (explicit project override).
458
479
  const hooksAdapter =
459
480
  (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
@@ -499,6 +520,7 @@ export function resolveControl({
499
520
  hooksSessionStartBranchHint,
500
521
  hooksAdapter,
501
522
  hooksInstructions,
523
+ updatesNotify,
502
524
  };
503
525
  }
504
526
 
@@ -253,6 +253,36 @@ export function diffGroups(offline = {}, remote = {}) {
253
253
  // too terse to carry a durable observation (e.g. "yes", "fixed", "todo").
254
254
  export const MIN_VALUE_LEN = 12;
255
255
 
256
+ // Blank the INTERIOR of fenced code blocks (``` or ~~~ fences) so example content
257
+ // — a lesson documenting an `<!-- MARKER -->` or pasting a ```yaml front-matter
258
+ // sample — is never mistaken for hidden metadata. Such content renders as VISIBLE
259
+ // fenced text, the opposite of the digest-hidden block `hidden-metadata` targets,
260
+ // and `lint` is a CI gate, so a false positive there fails a legitimate lesson.
261
+ // Line positions are preserved (interiors and fence lines become empty) so the
262
+ // heading-anchored checks still see the real document structure. A closing fence
263
+ // is ≥3 of the SAME character as the opener with no trailing info string
264
+ // (CommonMark); an opener may carry an info string (```yaml). Pure.
265
+ function stripFencedCode(value) {
266
+ let fence = null; // the active fence character (` or ~), or null when outside a block
267
+ return String(value)
268
+ .split('\n')
269
+ .map((line) => {
270
+ const m = line.match(/^\s*(`{3,}|~{3,})/);
271
+ if (fence === null) {
272
+ if (m) {
273
+ fence = m[1][0];
274
+ return '';
275
+ }
276
+ return line;
277
+ }
278
+ if (m && m[1][0] === fence && line.trim().replace(/[`~\s]/g, '') === '') {
279
+ fence = null;
280
+ }
281
+ return '';
282
+ })
283
+ .join('\n');
284
+ }
285
+
256
286
  // The lint rule set: each a pure predicate over a normalized entry returning a
257
287
  // short reason string when it FIRES, or null when the entry is clean. Kept as
258
288
  // discrete named functions so each rule is independently unit-testable and the
@@ -335,6 +365,47 @@ export const LINT_RULES = {
335
365
  if (parsed === null || typeof parsed !== 'object') return null; // a bare scalar — short-value's to catch when short, otherwise unjudged.
336
366
  return 'value is a JSON object/array with no kind set — it renders as a raw JSON blob in every SessionStart digest; set --kind bus or --kind signal';
337
367
  },
368
+ // A lesson body is markdown for humans — no HTML comment, no front-matter, no
369
+ // `key=value` header. The `<!-- meta: seen_count=… status=… trigger-context=… -->`
370
+ // block is a REPUDIATED legacy convention this repo's lorekit-setup skill once
371
+ // prescribed (still parsed as a read-fallback in `candidates-pure.mjs` /
372
+ // `commands/invariants.mjs`, and skipped by the digest preview in `core/lessons.mjs`).
373
+ // It is wrong on the merits: an HTML comment renders to nothing, so a human sees a
374
+ // lesson starting mid-sentence, while a baked-in `seen_count`/`status`/`trigger`
375
+ // silently disagrees with the store's own column/tag/field. Conservative like the
376
+ // other rules — three concrete shapes only, never a fuzzy "looks like metadata":
377
+ // 1. any HTML comment (`<!--`), the strongest and most common offender;
378
+ // 2. a body that OPENS with a YAML front-matter block (`---` as the first line);
379
+ // 3. a machine-metadata header (`meta:`, `seen_count`, `status=`, `expires`,
380
+ // `ttl(_days)`, `trigger-context`) appearing BEFORE the first `#` title, so a
381
+ // legitimate prose line mid-body never trips it.
382
+ // All three run against a fence-stripped copy (`stripFencedCode`) so a lesson that
383
+ // DOCUMENTS one of these shapes inside a code block is never flagged.
384
+ 'hidden-metadata': (e) => {
385
+ const raw = String(e.value ?? '');
386
+ if (!raw.trim()) return null; // an empty value is `empty-value`'s to report.
387
+ const v = stripFencedCode(raw);
388
+ if (!v.trim()) return null; // nothing outside code fences — no prose metadata to flag.
389
+ if (v.includes('<!--')) {
390
+ return "value contains an HTML comment — lesson bodies are pure markdown; move seen_count → the column, status → a status:: tag, trigger → the trigger field";
391
+ }
392
+ const lines = v.split('\n');
393
+ const firstNonEmpty = lines.find((l) => l.trim() !== '');
394
+ if (firstNonEmpty !== undefined && firstNonEmpty.trim() === '---') {
395
+ return 'value opens with a front-matter block — lesson bodies carry no front-matter; every stored fact belongs in its own write field';
396
+ }
397
+ const headingIdx = lines.findIndex((l) => /^\s*#/.test(l));
398
+ if (headingIdx > 0) {
399
+ const metaHeader = lines
400
+ .slice(0, headingIdx)
401
+ .filter((l) => l.trim() !== '')
402
+ .find((l) => /^\s*(meta\b|seen_count\b|status\s*=|expires\b|ttl(?:_days)?\b|trigger[-_]context\b)/i.test(l));
403
+ if (metaHeader) {
404
+ return `value has a machine-metadata header before the title ('${metaHeader.trim().slice(0, 40)}') — move it to the store's own fields`;
405
+ }
406
+ }
407
+ return null;
408
+ },
338
409
  };
339
410
 
340
411
  // Run every lint rule against one normalized entry, returning the findings it
@@ -0,0 +1,148 @@
1
+ // Offline "installed vs shipped" skill version check.
2
+ //
3
+ // `lorekit install` copies each skill's SKILL.md verbatim and stamps no version
4
+ // anywhere else, so a skill installed at an old version has reported a healthy
5
+ // `doctor` PASS forever with no signal to update. The version data already
6
+ // exists: every skill's `SKILL.md` frontmatter carries `metadata.version`
7
+ // (e.g. `'1.0.0'`), and the shipped skill source travels in the SAME npm
8
+ // tarball as the running CLI (`SKILLS[].source` in `./config.mjs`) — so
9
+ // comparing "what's on disk" against "what this CLI ships" needs zero network
10
+ // calls and no server round-trip. `doctor` (per-scope reporting) and
11
+ // `lorekit update` (the refresh command) and the SessionStart drift nudge
12
+ // (`../core/update-notify.mjs`) all read through this one module so the three
13
+ // surfaces can't disagree about what counts as outdated.
14
+ //
15
+ // Dependency-free `.mjs`, like the other `shared/*.mjs` pure modules — no npm
16
+ // imports beyond node builtins.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { SKILLS, skillInstallDir } from './config.mjs';
20
+
21
+ /**
22
+ * Pull the YAML frontmatter block out of a SKILL.md body — the text between
23
+ * the first `---` line and the next one. `null` when there is no such block
24
+ * (not a SKILL.md-shaped file, or a corrupt/truncated one).
25
+ */
26
+ export function extractFrontmatter(markdown) {
27
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(typeof markdown === 'string' ? markdown : '');
28
+ return m ? m[1] : null;
29
+ }
30
+
31
+ /**
32
+ * Parse `metadata.version` out of a SKILL.md's frontmatter.
33
+ *
34
+ * Deliberately minimal: every SKILL.md this CLI ships has exactly one
35
+ * `version:` line (nested under `metadata:`), so a plain line match — robust
36
+ * to single/double quotes and a trailing comment — is enough without pulling
37
+ * in a YAML parser this zero-dep package does not otherwise need. Returns
38
+ * `null` when the frontmatter is absent or carries no `version:` line at all
39
+ * (a legacy skill file predating the version stamp).
40
+ */
41
+ export function parseSkillVersion(markdown) {
42
+ const frontmatter = extractFrontmatter(markdown);
43
+ if (!frontmatter) return null;
44
+ const m = /^[ \t]*version:[ \t]*(['"]?)([^'"\n#]+?)\1[ \t]*(?:#.*)?$/m.exec(frontmatter);
45
+ if (!m) return null;
46
+ const version = m[2].trim();
47
+ return version || null;
48
+ }
49
+
50
+ // Read + parse a SKILL.md's version, or `null` for anything that isn't a
51
+ // readable, parseable file — an absent path, a permissions error, and a file
52
+ // with no `version:` line all collapse to the same "unknown" signal.
53
+ function readVersion(skillMdPath) {
54
+ try {
55
+ return parseSkillVersion(fs.readFileSync(skillMdPath, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Dependency-free semver-ish compare of two dotted-numeric version strings.
63
+ *
64
+ * Returns `-1` / `0` / `1` the usual way, or `null` when either input is not a
65
+ * usable version string (missing, empty, or containing a non-numeric segment —
66
+ * pre-release/build metadata like `1.0.0-beta` is intentionally out of scope,
67
+ * since every shipped SKILL.md version is a plain `x.y.z`). `null` reads as
68
+ * "cannot compare", which callers treat as `unknown` rather than guessing a
69
+ * direction.
70
+ */
71
+ export function compareVersions(a, b) {
72
+ if (typeof a !== 'string' || typeof b !== 'string' || !a.trim() || !b.trim()) return null;
73
+ const toParts = (v) => v.trim().split('.').map((seg) => Number(seg));
74
+ const pa = toParts(a);
75
+ const pb = toParts(b);
76
+ if (pa.some((n) => !Number.isFinite(n)) || pb.some((n) => !Number.isFinite(n))) return null;
77
+ const len = Math.max(pa.length, pb.length);
78
+ for (let i = 0; i < len; i++) {
79
+ const x = pa[i] ?? 0;
80
+ const y = pb[i] ?? 0;
81
+ if (x < y) return -1;
82
+ if (x > y) return 1;
83
+ }
84
+ return 0;
85
+ }
86
+
87
+ /** The scopes a skill can be installed in — matches `skillInstallDir`'s. */
88
+ export const SKILL_SCOPES = ['project', 'global'];
89
+
90
+ /**
91
+ * Classify one (skill, scope) pair against the version this CLI ships.
92
+ *
93
+ * `state`:
94
+ * - `not-installed` — no SKILL.md at that scope.
95
+ * - `current` — installed version >= shipped version.
96
+ * - `outdated` — installed version < shipped version.
97
+ * - `unknown` — a SKILL.md exists but a version could not be
98
+ * resolved on one side (an unparseable/legacy
99
+ * installed file, or — in practice never — an
100
+ * unparseable shipped one), so no direction can be
101
+ * asserted.
102
+ */
103
+ function classify(installedPath, shipped) {
104
+ const installedExists = fs.existsSync(installedPath);
105
+ if (!installedExists) {
106
+ return { installed: null, state: 'not-installed' };
107
+ }
108
+ const installed = readVersion(installedPath);
109
+ if (installed == null || shipped == null) {
110
+ return { installed, state: 'unknown' };
111
+ }
112
+ const cmp = compareVersions(installed, shipped);
113
+ if (cmp === null) return { installed, state: 'unknown' };
114
+ return { installed, state: cmp < 0 ? 'outdated' : 'current' };
115
+ }
116
+
117
+ /**
118
+ * Check every skill in `skills` (default: the CLI's own `SKILLS` list) at
119
+ * every scope, comparing the INSTALLED `SKILL.md`'s `metadata.version`
120
+ * against the SHIPPED source's. Pure filesystem reads — no network, no store.
121
+ *
122
+ * Returns one row per (skill, scope):
123
+ * `{ name, scope, installedPath, installed, shipped, state }`
124
+ */
125
+ export function checkSkillVersions(root, { skills = SKILLS } = {}) {
126
+ const results = [];
127
+ for (const skill of skills) {
128
+ const shipped = readVersion(path.join(skill.source, 'SKILL.md'));
129
+ for (const scope of SKILL_SCOPES) {
130
+ const installedPath = path.join(skillInstallDir(root, scope, skill.name), 'SKILL.md');
131
+ const { installed, state } = classify(installedPath, shipped);
132
+ results.push({ name: skill.name, scope, installedPath, installed, shipped, state });
133
+ }
134
+ }
135
+ return results;
136
+ }
137
+
138
+ /**
139
+ * The subset of `checkSkillVersions` results that need an update — `outdated`
140
+ * or `unknown` (a legacy install with no readable version is exactly the case
141
+ * `lorekit update` exists to repair, since re-copying stamps a fresh one).
142
+ * `not-installed` and `current` are excluded — neither one is drift to act on.
143
+ */
144
+ export function skillsNeedingUpdate(results) {
145
+ return (Array.isArray(results) ? results : []).filter(
146
+ (r) => r && (r.state === 'outdated' || r.state === 'unknown'),
147
+ );
148
+ }