@lorekit/cli 1.39.0 → 1.39.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.39.0",
3
+ "version": "1.39.1",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,10 @@ import { deriveScope } from '../scope.mjs';
13
13
  // must be able to reuse it rather than grow a second ranking with its own idea
14
14
  // of what "most useful" means.
15
15
  import { resolvePrecedence, rankLessons } from '../lessons-pure.mjs';
16
+ // The store's own scope inventory, normalised — the SAME helper `memory.scopes`
17
+ // uses, so the map and the MCP tool cannot disagree about what a scope holds or
18
+ // about what a failed enumeration looks like.
19
+ import { readScopeInventory } from '../store/scope-inventory.mjs';
16
20
  // The deep-link builder is the SAME pure module the `link` command and the
17
21
  // `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
18
22
  // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
@@ -93,6 +97,19 @@ export const SCOPE_READ_LIMIT = 25;
93
97
 
94
98
  export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
95
99
  const scope = deriveScope(cwd);
100
+ // Issued BEFORE the per-scope read loop and awaited after it. Nothing in the
101
+ // inventory depends on the loop, so awaiting it afterwards would cost a
102
+ // remote store one extra SERIAL round-trip on the session-start path; started
103
+ // here it overlaps the per-scope reads instead. On a local store `listScopes`
104
+ // is synchronous under its async signature, so the overlap is nil there and
105
+ // the ordering is merely harmless.
106
+ //
107
+ // Leaving the promise unawaited across the loop is safe because
108
+ // `readScopeInventory` cannot reject: its "store cannot enumerate" guard
109
+ // returns before the `try`, and the `try` covers both a synchronous throw and
110
+ // a rejected `listScopes()`. Keep that property if either is ever touched — a
111
+ // floating promise that can reject would take the hook down with it.
112
+ const inventoryPromise = readScopeInventory(store);
96
113
  const groups = [];
97
114
  // Per scope: did the read come back full? Then the count below is a floor,
98
115
  // not a total, and the map must say so rather than quietly under-report.
@@ -152,11 +169,55 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
152
169
  // build its array.
153
170
  const ranked = rankLessons(winners, { terms: [], now, scopeOrder: scope.readOrder });
154
171
 
155
- // The scope map is built from the SAME pass, over the winners — the set a
156
- // reader could actually act on, so a shadowed duplicate is not counted twice.
157
- // Order follows `readOrder` (most-specific first) rather than count, because
158
- // the map is a map: it should read like the hierarchy it describes.
159
- const scopeCounts = scopeInventory(ranked, scope.readOrder, truncatedScopes);
172
+ // ── the scope map: EXACT counts when the store can enumerate ───────────────
173
+ //
174
+ // The map's job is to tell a reader how much lore is sitting in each scope
175
+ // that this injection did not show them, so its numbers should be the store's
176
+ // real totals. Deriving them from the bounded read above cannot do that: the
177
+ // read stops at `SCOPE_READ_LIMIT`, so a scope holding 400 lessons reported
178
+ // `25+` — technically honest, useless as a quantity, and the `+` was doing a
179
+ // lot of work.
180
+ //
181
+ // `listScopes()` answers exactly, at any size, on BOTH stores: the local one
182
+ // walks its own tree, and the remote one hits `GET /memories/scopes`, which
183
+ // aggregates in Postgres (migration 00039) rather than counting rows a
184
+ // response cap may have truncated. That is the same reason `stats` and
185
+ // `scopes` were moved onto it.
186
+ //
187
+ // TWO SEMANTIC DIFFERENCES, both deliberate, because the map answers a
188
+ // different question from the injected set:
189
+ //
190
+ // 1. It counts EVERY active lesson in the scope, not just the ones that
191
+ // survived precedence. A key shadowed at a broader scope is still a real
192
+ // row you can `memory.read` there, and the map is a pointer to what
193
+ // exists — not a summary of what was injected.
194
+ // 2. It is not bounded by what this session happened to read, so a scope
195
+ // whose lessons all lost the ranking still appears, which is precisely
196
+ // when a reader most needs telling it is there.
197
+ //
198
+ // THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED. Exactness is not free
199
+ // on a local or two-tier store: `listScopes()` there is `_walkEntries()`
200
+ // (`store/local.mjs`), which reads and parses EVERY lesson file under the
201
+ // base dir — including the scopes outside `readOrder` that the narrowing
202
+ // below then throws away. So a session start now pays a store-wide walk where
203
+ // the derived counts cost nothing beyond the bounded read it already did. It
204
+ // is accepted deliberately: the walk is local disk over a store of markdown
205
+ // files, the hosted path aggregates in Postgres instead of walking anything,
206
+ // and the alternative — bounding the enumeration — reinstates the `25+` floor
207
+ // this change exists to remove. Narrowing the walk would mean a scope filter
208
+ // on the store contract, which is a change to all three implementations and
209
+ // to `memory.scopes`; if this ever shows up in a session-start profile, that
210
+ // is the fix, not a smaller limit here.
211
+ //
212
+ // Best-effort, like everything on this path: an unreachable remote, a store
213
+ // with no `listScopes`, or a throw all fall back to the derived counts —
214
+ // approximate and `+`-suffixed, exactly what shipped before — rather than
215
+ // costing the user their scope map. `readScopeInventory` never throws.
216
+ const inventory = await inventoryPromise;
217
+ const derivedCounts = scopeInventory(ranked, scope.readOrder, truncatedScopes);
218
+ const scopeCounts = inventory.ok
219
+ ? scopeInventoryFromStore(inventory.scopes, scope.readOrder, derivedCounts)
220
+ : derivedCounts;
160
221
 
161
222
  // `applicable` is the honest denominator for the header — how many the reader
162
223
  // has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
@@ -169,6 +230,49 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
169
230
  };
170
231
  }
171
232
 
233
+ // Per-scope counts from the STORE's own inventory, narrowed to the scopes this
234
+ // working directory reads and ordered by the hierarchy.
235
+ //
236
+ // The narrowing is the point: `listScopes()` is deliberately store-wide (it
237
+ // backs the `scopes` command, which enumerates everywhere), but the SessionStart
238
+ // map describes THIS workspace. Naming a scope the reader is not working in
239
+ // would be noise dressed up as guidance.
240
+ //
241
+ // An enumerated row is exact and so never carries `atReadLimit` — the `+`
242
+ // suffix exists to admit that a number is a floor, and an enumerated one is
243
+ // not. A row that fell back to the derived count keeps the flag it came with,
244
+ // because that number IS a floor. A scope with no active
245
+ // lesson is omitted, matching `scopeInventory`: a row reading `0` is noise, and
246
+ // there is nothing to drill into. Pure.
247
+ // `fallback` is the derived inventory — the same rows the failure path uses —
248
+ // and it is consulted PER SCOPE, not only when the whole enumeration failed.
249
+ // `ok: true` means the store answered, not that the answer is complete: a row
250
+ // can be missing, or carry a count that `shapeScopeRow` had to coerce to 0.
251
+ // Without the per-scope fallback a scope that just contributed injected lessons
252
+ // would drop off the map entirely — the reader would see lore in the digest
253
+ // with no row saying where it lives, which is a worse answer than the
254
+ // approximate one this had in hand all along.
255
+ export function scopeInventoryFromStore(scopes, scopeOrder = [], fallback = []) {
256
+ const counts = new Map();
257
+ for (const row of Array.isArray(scopes) ? scopes : []) {
258
+ const s = row?.scope;
259
+ if (!s) continue;
260
+ const n = Number(row.count);
261
+ if (Number.isFinite(n) && n > 0) counts.set(s, n);
262
+ }
263
+ const derived = new Map();
264
+ for (const row of Array.isArray(fallback) ? fallback : []) {
265
+ if (row?.scope) derived.set(row.scope, row);
266
+ }
267
+ return (Array.isArray(scopeOrder) ? scopeOrder : [])
268
+ .filter((s) => counts.has(s) || derived.has(s))
269
+ .map((s) => (counts.has(s)
270
+ ? { scope: s, count: counts.get(s), atReadLimit: false }
271
+ // The derived row keeps its own `atReadLimit`, so a fallen-back scope
272
+ // still renders `25+` rather than posing as an exact number.
273
+ : { ...derived.get(s) }));
274
+ }
275
+
172
276
  // Per-scope counts over an already-ranked lesson list, in the given scope order.
173
277
  // A scope with no surviving lesson is omitted — a map row reading `0` is noise,
174
278
  // and the reader cannot act on an empty scope. Pure.
@@ -29,6 +29,7 @@ import { loadControl } from './control.mjs';
29
29
  import { createStore } from './store/index.mjs';
30
30
  import { createRemoteStore } from './store/remote.mjs';
31
31
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
32
+ import { readScopeInventory } from './store/scope-inventory.mjs';
32
33
 
33
34
  const PROTOCOL_VERSION = '2024-11-05';
34
35
  const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
@@ -239,13 +240,15 @@ const MEMORY_DISPATCH = {
239
240
  // /memories/scopes` and the `lorekit scopes` command have answered this since
240
241
  // migration 00039; the MCP surface was the one caller that could not ask.
241
242
  //
242
- // THE TWO STORES ANSWER IN DIFFERENT SHAPES, and normalising here is the whole
243
- // job of this function. `LocalStore`/`TwoTierStore.listScopes()` return a BARE
244
- // ARRAY (`[{ scope, count }]`), while `RemoteStore.listScopes()` returns the
245
- // standard `{ ok, scopes }` envelope — or `{ ok: false, error, networkError,
246
- // unusable }`. A tool that passed either through verbatim would hand the model
247
- // two different contracts for one tool name depending on a config value it
248
- // cannot see.
243
+ // THE TWO STORES ANSWER IN DIFFERENT SHAPES, and reconciling them is NO LONGER
244
+ // this function's job it moved to `store/scope-inventory.mjs`, which the
245
+ // SessionStart scope map reads too. `LocalStore`/`TwoTierStore.listScopes()`
246
+ // return a BARE ARRAY (`[{ scope, count }]`), while `RemoteStore.listScopes()`
247
+ // returns the standard `{ ok, scopes }` envelope or `{ ok: false, error,
248
+ // networkError, unusable }`. A tool that passed either through verbatim would
249
+ // hand the model two different contracts for one tool name depending on a
250
+ // config value it cannot see. What is left here is what only the MCP surface
251
+ // owns: the ascending sort and the exit-clean degradation below.
249
252
  //
250
253
  // DEGRADATION IS EXIT-CLEAN, mirroring the `scopes` command, which reports an
251
254
  // unreachable remote as a short note at exit 0 rather than failing the run. An
@@ -255,23 +258,16 @@ const MEMORY_DISPATCH = {
255
258
  // tool-level error is liable to retry it rather than carry on with the lore it
256
259
  // can already reach. The note says which, in bounded, non-PII terms.
257
260
  export async function listScopes(store) {
258
- let res;
259
- try {
260
- res = await store.listScopes();
261
- } catch (e) {
262
- // A store that cannot enumerate must not take the session down with it.
263
- return { ok: true, scopes: [], note: `scope enumeration failed: ${errText(e)}` };
264
- }
265
-
266
- // Local/two-tier: the bare array form.
267
- if (Array.isArray(res)) return { ok: true, scopes: sortScopes(res.map(shapeScope)) };
268
-
269
- // Remote: the envelope form.
270
- if (res && res.ok) {
271
- return { ok: true, scopes: sortScopes((Array.isArray(res.scopes) ? res.scopes : []).map(shapeScope)) };
272
- }
273
-
274
- return { ok: true, scopes: [], note: scopeFailureNote(res) };
261
+ // The array-vs-envelope branch and the failure vocabulary live in the shared
262
+ // `store/scope-inventory.mjs` — the SessionStart scope map needs the same
263
+ // normalisation, and two copies of "what does a failed enumeration look like"
264
+ // is how the two surfaces end up disagreeing about it.
265
+ const { ok, scopes, reason } = await readScopeInventory(store);
266
+ // `ok: true` either way: an enumeration that could not run is a fact about
267
+ // the store, not a failed tool call, so it must not reach `toolResult` as an
268
+ // `isError` a model is liable to retry instead of carrying on with the lore
269
+ // it can already reach.
270
+ return ok ? { ok: true, scopes: sortScopes(scopes) } : { ok: true, scopes: [], note: reason };
275
271
  }
276
272
 
277
273
  // Sorted by scope ascending, which is the contract `docs/mcp-tools.md`, the
@@ -301,42 +297,6 @@ function sortScopes(rows) {
301
297
  return rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
302
298
  }
303
299
 
304
- // One inventory row. `last_activity` is passed through when the store supplied
305
- // it (the hosted `GET /memories/scopes` has returned it since migration 00049)
306
- // and OMITTED — never null — when it did not, so a client can tell "this store
307
- // does not report freshness" from "this scope has no activity".
308
- function shapeScope(s) {
309
- const scope = String(s?.scope ?? '');
310
- const count = Number(s?.count);
311
- const row = { scope, count: Number.isFinite(count) ? count : 0 };
312
- const last = s?.last_activity ?? s?.lastActivity;
313
- return last ? { ...row, last_activity: last } : row;
314
- }
315
-
316
- // A short, bounded reason an enumeration produced nothing. Deliberately built
317
- // here rather than reusing `lessons-view.mjs`'s `describeError`: that module
318
- // carries the whole render/`util` stack, and this server has kept clear of it.
319
- // The vocabulary matches what that helper reports, so the two read alike.
320
- function scopeFailureNote(res) {
321
- if (!res) return 'the store returned no result';
322
- if (res.unusable) return 'no usable store is configured';
323
- if (res.networkError) return `network error: ${String(res.networkError).slice(0, 200)}`;
324
- // `httpStatus` is the ONLY field that carries a real status: `restFetch`'s
325
- // error object is `{ message, code }`, and `code` is the response body's own
326
- // application code on a JSON error, so rendering it as "HTTP <code>" would
327
- // print a non-status. Read the top-level field first — that is the one
328
- // `RemoteStore.listScopes()` passes through — and keep the nested read as a
329
- // tolerance for any store that nests it instead.
330
- const status = res.httpStatus ?? res.error?.httpStatus;
331
- if (status) return `request failed with HTTP ${status}`;
332
- if (res.error?.message) return String(res.error.message).slice(0, 200);
333
- return 'the store could not enumerate its scopes';
334
- }
335
-
336
- function errText(e) {
337
- return String(e?.message ?? e).slice(0, 200);
338
- }
339
-
340
300
  // Provenance for a tool call: the caller's explicit values win, the working
341
301
  // directory and CI environment fill the rest. Best-effort — a failure to shell
342
302
  // out to git must never fail the write, so it degrades to no origin at all.
@@ -262,8 +262,8 @@ class RemoteStore {
262
262
  // where `code` is the response body's own application code on a JSON error
263
263
  // (a string like `permission_denied`) and only incidentally the status on a
264
264
  // non-JSON one. A consumer that wants to say "HTTP 403" must therefore read
265
- // `httpStatus`, never `error.code` — `mcp-server.mjs`'s `scopeFailureNote`
266
- // does exactly that, and it had nothing to read until this field was passed
265
+ // `httpStatus`, never `error.code` — `store/scope-inventory.mjs`'s
266
+ // `failureReason` does exactly that, and it had nothing to read until this field was passed
267
267
  // through. Additive: `scopes.mjs`, `stats.mjs` and `lessons-view.mjs` all
268
268
  // branch on `ok` / `unusable` / `networkError` and ignore the extra key.
269
269
  async listScopes() {
@@ -0,0 +1,123 @@
1
+ // Normalising a store's scope inventory into ONE shape.
2
+ //
3
+ // `listScopes()` is the only store method whose two implementations disagree
4
+ // about their envelope: `LocalStore`/`TwoTierStore` return a BARE ARRAY of
5
+ // `{ scope, count }`, while `RemoteStore` returns the standard
6
+ // `{ ok, scopes }` — or `{ ok: false, error, networkError, unusable }`. Every
7
+ // caller that wants an inventory therefore has to know which store it is
8
+ // holding, which is exactly the knowledge a caller should not need.
9
+ //
10
+ // Two callers now want one: the `memory.scopes` MCP tool and the SessionStart
11
+ // scope map. Before this module they each carried their own copy of the
12
+ // array-vs-envelope branch, which is two places to get "what does a failed
13
+ // enumeration look like" subtly different.
14
+ //
15
+ // TOTAL FUNCTION. Anything unrecognisable — a null, a rejected promise's value,
16
+ // a row with a missing count — degrades to a usable answer with a reason
17
+ // attached. Both callers are best-effort paths: the MCP tool must not turn "I
18
+ // could not enumerate" into a tool error, and the hook must not lose a lesson
19
+ // injection over a failed count. Neither can afford a throw.
20
+ //
21
+ // Zero-dependency: no imports, not even node builtins.
22
+
23
+ /**
24
+ * Normalise whatever `listScopes()` returned into `{ ok, scopes, reason }`.
25
+ *
26
+ * `ok` is false ONLY when the store could not answer. An empty store is a
27
+ * SUCCESSFUL enumeration that found nothing (`ok: true`, `scopes: []`) — the
28
+ * distinction matters, because "no scopes" and "I could not look" lead a caller
29
+ * to different behaviour, and collapsing them is how a transient network error
30
+ * ends up rendering as an authoritative empty inventory.
31
+ *
32
+ * `reason` is a short, bounded, non-PII note when `ok` is false, and null
33
+ * otherwise. The vocabulary matches `lessons-view.mjs`'s `describeError`, so
34
+ * the notes read alike wherever they surface — but it is built here rather than
35
+ * imported, because this module is reached from the SessionStart hot path and
36
+ * that one carries the whole render/`util` stack.
37
+ */
38
+ export function normalizeScopeInventory(result) {
39
+ // Local / two-tier: the bare array form.
40
+ if (Array.isArray(result)) return { ok: true, scopes: result.map(shapeScopeRow), reason: null };
41
+
42
+ // Remote: the envelope form.
43
+ if (result && result.ok) {
44
+ const scopes = Array.isArray(result.scopes) ? result.scopes : [];
45
+ return { ok: true, scopes: scopes.map(shapeScopeRow), reason: null };
46
+ }
47
+
48
+ return { ok: false, scopes: [], reason: failureReason(result) };
49
+ }
50
+
51
+ /**
52
+ * One inventory row, coerced.
53
+ *
54
+ * `last_activity` is passed through when the store supplied it (the hosted
55
+ * `GET /memories/scopes` has returned it since migration 00049) and OMITTED —
56
+ * never null — when it did not, so a consumer can tell "this store does not
57
+ * report freshness" from "this scope has none".
58
+ *
59
+ * ONE DELIBERATE DIVERGENCE from the `shapeScope` this replaced, named here so
60
+ * it is not mistaken for an accident: the count is clamped at 0, where the old
61
+ * helper passed a negative through. A negative count is not a quantity any
62
+ * store can honestly report — `LocalStore` increments a counter and the hosted
63
+ * route is a `count(*)` — so it can only ever be a malformed row, and `-3` in a
64
+ * `memory.scopes` answer is worse than `0`. Unreachable from either real store;
65
+ * it is the coercion boundary being total, not a behaviour anyone can observe.
66
+ */
67
+ export function shapeScopeRow(s) {
68
+ const count = Number(s?.count);
69
+ const row = { scope: String(s?.scope ?? ''), count: Number.isFinite(count) ? Math.max(0, count) : 0 };
70
+ const last = s?.last_activity ?? s?.lastActivity;
71
+ return last ? { ...row, last_activity: last } : row;
72
+ }
73
+
74
+ /** A short, bounded reason an enumeration produced nothing. */
75
+ export function failureReason(result) {
76
+ if (!result) return 'the store returned no result';
77
+ if (result.unusable) return 'no usable store is configured';
78
+ if (result.networkError) return `network error: ${clip(result.networkError)}`;
79
+ // `httpStatus` is the ONLY field carrying a real status: `restFetch`'s error
80
+ // object is `{ message, code }`, and `code` is the response body's own
81
+ // application code on a JSON error, so rendering it as "HTTP <code>" would
82
+ // print a non-status. Read the top-level field first — that is the one
83
+ // `RemoteStore.listScopes()` passes through — and keep the nested read as a
84
+ // tolerance for any store that nests it instead.
85
+ const status = result.httpStatus ?? result.error?.httpStatus;
86
+ if (status) return `request failed with HTTP ${status}`;
87
+ if (result.error?.message) return clip(result.error.message);
88
+ return 'the store could not enumerate its scopes';
89
+ }
90
+
91
+ function clip(v) {
92
+ return String(v).slice(0, 200);
93
+ }
94
+
95
+ /**
96
+ * Ask a store for its inventory without letting it fail the caller.
97
+ *
98
+ * A store need not implement `listScopes` at all (a stub, a fixture, a future
99
+ * adapter), and one that does may still throw. Both are ordinary here, so both
100
+ * come back as `ok: false` with a reason rather than as an exception the caller
101
+ * has to remember to catch.
102
+ *
103
+ * THE SECOND DELIBERATE DIVERGENCE from what `memory.scopes` used to answer,
104
+ * named here for the same reason as `shapeScopeRow`'s clamp. The old
105
+ * `mcp-server.mjs` path called `store.listScopes()` unguarded and let the
106
+ * resulting `TypeError` fall into its catch, so a store without the method
107
+ * reported `scope enumeration failed: store.listScopes is not a function` — an
108
+ * internal symbol leaked into a user-facing note, and indistinguishable from a
109
+ * store whose enumeration genuinely blew up. The missing method is checked
110
+ * first now and reported as `this store cannot enumerate scopes`; the throwing
111
+ * case keeps the `scope enumeration failed: <message>` wording verbatim. Two
112
+ * different facts, two different notes.
113
+ */
114
+ export async function readScopeInventory(store) {
115
+ if (!store || typeof store.listScopes !== 'function') {
116
+ return { ok: false, scopes: [], reason: 'this store cannot enumerate scopes' };
117
+ }
118
+ try {
119
+ return normalizeScopeInventory(await store.listScopes());
120
+ } catch (e) {
121
+ return { ok: false, scopes: [], reason: `scope enumeration failed: ${clip(e?.message ?? e)}` };
122
+ }
123
+ }