@lorekit/cli 1.47.0 → 1.49.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.47.0",
3
+ "version": "1.49.0",
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": {
@@ -40,7 +40,7 @@ export function scopeList({ projectScope, branchScope, repoScope } = {}) {
40
40
  // deliberately small and in lockstep with that source, including the 64-char
41
41
  // host clamp. Lets the offline store, whose rows carry no kind/host column,
42
42
  // still be filtered and badged by taxonomy from the tags it does store.
43
- function inferKindHostFromTags(tags) {
43
+ export function inferKindHostFromTags(tags) {
44
44
  if (!Array.isArray(tags)) return {};
45
45
  for (const tag of tags) {
46
46
  if (tag === 'loop::review-outcomes') return { kind: 'bus', host: 'review' };
@@ -30,6 +30,7 @@ import { createStore } from './store/index.mjs';
30
30
  import { createRemoteStore } from './store/remote.mjs';
31
31
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
32
32
  import { readScopeInventory } from './store/scope-inventory.mjs';
33
+ import { inferKindHostFromTags } from './lessons-view.mjs';
33
34
 
34
35
  const PROTOCOL_VERSION = '2024-11-05';
35
36
  const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
@@ -108,7 +109,10 @@ export const MEMORY_TOOL_DEFS = [
108
109
  scope: { type: 'string' },
109
110
  tags: { type: 'array', items: { type: 'string' } },
110
111
  limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
111
- cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
112
+ cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page. Ignored when kind or host is set — a taxonomy-filtered list is a single bounded page (nextCursor is always null); raise limit rather than paginating.' },
113
+ kind: { type: 'string', enum: ['lesson', 'bus', 'signal'], description: 'Filter to one bucket family. Narrowed server-side against the remote store; post-filtered client-side against the local store, whose rows carry no kind/host columns and are classified from their loop:: tag.' },
114
+ host: { type: 'string', description: 'Filter to the owning skill or agent, e.g. `reviewer`. Same server-side/client-side split as kind.' },
115
+ view: { type: 'string', enum: ['full', 'summary'], default: 'full', description: 'summary omits each entry\'s value and returns value_bytes + a 200-character preview instead.' },
112
116
  },
113
117
  },
114
118
  },
@@ -213,6 +217,167 @@ export const ORG_TOOL_DEFS = [
213
217
  // Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
214
218
  export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
215
219
 
220
+ /** Characters of `value` echoed in a `view: "summary"` entry's `preview`. */
221
+ export const LIST_PREVIEW_CHARS = 200;
222
+
223
+ /** The closed `view` vocabulary, mirroring `MemoryListViewSchema`. */
224
+ const LIST_VIEWS = ['full', 'summary'];
225
+
226
+ /** The closed `kind` vocabulary, mirroring `MemoryKindSchema`. */
227
+ const MEMORY_KINDS = ['lesson', 'bus', 'signal'];
228
+
229
+ /**
230
+ * Validate the taxonomy/projection arguments of a `memory.list` call.
231
+ *
232
+ * Every other surface REJECTS an out-of-vocabulary value — the edge throws
233
+ * `UserInputError`, `ListInputSchema` fails the parse. Letting a typo fall
234
+ * through to the default here would make `lorekit mcp` the one path where
235
+ * `view: "sumary"` silently returns full bodies, or `kind: "lessons"` silently
236
+ * returns every bucket. Throwing keeps the contract uniform.
237
+ */
238
+ function validateListArgs(a = {}) {
239
+ if (a.view !== undefined && !LIST_VIEWS.includes(a.view)) {
240
+ throw new Error(`Invalid view "${a.view}": expected "full" or "summary"`);
241
+ }
242
+ if (a.kind !== undefined && !MEMORY_KINDS.includes(a.kind)) {
243
+ throw new Error(`Invalid kind "${a.kind}": expected "lesson", "bus" or "signal"`);
244
+ }
245
+ if (a.host !== undefined && (typeof a.host !== 'string' || a.host.length === 0 || a.host.length > 64)) {
246
+ throw new Error('Invalid host: expected a non-empty string of at most 64 characters');
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Post-filter a list result by `kind` / `host`.
252
+ *
253
+ * The remote store forwards both to `GET /memories` and they are narrowed
254
+ * server-side; the LOCAL store has no kind/host columns and ignores them
255
+ * entirely, so without this a local call that asked to narrow would get the
256
+ * whole scope back and look filtered. This is the same post-filter the read
257
+ * commands already apply in `gather()`, for the same reason — and it is
258
+ * idempotent over already-narrowed remote rows.
259
+ *
260
+ * Taxonomy is taken from the stored columns when present, else inferred from
261
+ * the `loop::…` tag, so an offline row and a pre-`00056` remote row both filter
262
+ * the way a caller expects.
263
+ */
264
+ function filterListTaxonomy(result, { kind, host } = {}) {
265
+ if ((!kind && !host) || !result?.ok || !Array.isArray(result.entries)) return result;
266
+ const entries = result.entries.filter((e) => {
267
+ const inferred = inferKindHostFromTags(e?.tags);
268
+ const k = e?.kind ?? inferred.kind ?? null;
269
+ const h = e?.host ?? inferred.host ?? null;
270
+ return (!kind || k === kind) && (!host || h === host);
271
+ });
272
+ return { ...result, entries };
273
+ }
274
+
275
+ /**
276
+ * Apply the `view` projection to a store's list result.
277
+ *
278
+ * `full` (and an absent value) passes the result through untouched. `summary`
279
+ * swaps each entry's `value` for its byte size and a bounded prefix, so a
280
+ * discovery read costs an index instead of every body. An out-of-vocabulary
281
+ * value never reaches here — `validateListArgs` rejects it first.
282
+ *
283
+ * The slice is over `[...value]`, NOT `value.slice()`: JS string indices are
284
+ * UTF-16 code units, so a naive cut can land between a surrogate pair and emit
285
+ * a lone half. Spreading iterates code points, so an emoji or CJK character is
286
+ * never split. `value_bytes` is the UTF-8 byte length so it stays comparable
287
+ * with the 65,536-byte value cap.
288
+ */
289
+ export function projectListView(result, view) {
290
+ if (view !== 'summary' || !result?.ok || !Array.isArray(result.entries)) return result;
291
+ return {
292
+ ...result,
293
+ entries: result.entries.map(({ value, ...rest }) => ({
294
+ ...rest,
295
+ value_bytes: Buffer.byteLength(value ?? '', 'utf8'),
296
+ preview: [...(value ?? '')].slice(0, LIST_PREVIEW_CHARS).join(''),
297
+ })),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * How many rows to ask the store for when a taxonomy filter is active.
303
+ *
304
+ * Both stores apply `limit` BEFORE this module can post-filter — `LocalStore`
305
+ * and `TwoTierStore` slice in `list()`, and the remote route pages server-side.
306
+ * Without an over-fetch, `{ limit: 5, host: 'reviewer' }` over a scope holding
307
+ * 5 `aw` rows then 5 `reviewer` rows asks for 5, gets the 5 `aw` rows, filters
308
+ * them all away, and answers with zero entries — a silently empty read that
309
+ * looks like "no reviewer lessons exist".
310
+ *
311
+ * Over-fetching cannot be exact — only the server knows the true distribution —
312
+ * so the widened fetch is simply the largest page the backend will serve, and
313
+ * when it still comes back saturated the result carries `hasMore: true` so the
314
+ * caller knows the page was cut rather than exhausted.
315
+ *
316
+ * That maximum is **100**, and it is the route's constraint rather than a
317
+ * tuning choice: `ListMemoriesQuerySchema` caps `GET /memories`'s `limit` at
318
+ * 100, so asking for more is a 400 from the remote store — which would break
319
+ * `kind`/`host` for every request above `limit: 10` rather than merely
320
+ * under-filling it. Since the floor a scaled over-fetch would want is already
321
+ * at or above that cap for every supported `limit`, there is nothing to scale:
322
+ * one constant is the honest expression of the rule.
323
+ */
324
+ const TAXONOMY_FETCH_LIMIT = 100;
325
+
326
+ /**
327
+ * The full `memory.list` post-processing chain: validate → fetch → filter →
328
+ * slice → project.
329
+ *
330
+ * The slice happens HERE rather than in the store whenever a taxonomy filter is
331
+ * active, because the store cannot honour both `limit` and a filter it does not
332
+ * implement. See `TAXONOMY_FETCH_LIMIT` for why the fetch is widened.
333
+ */
334
+ export async function listWithFilters(store, a = {}) {
335
+ validateListArgs(a);
336
+ const filtering = Boolean(a.kind || a.host);
337
+ if (!filtering) return projectListView(await store.list(a), a.view);
338
+
339
+ const requested = a.limit ?? 50;
340
+ const widened = TAXONOMY_FETCH_LIMIT;
341
+ // Drop `cursor` as well as widening `limit`. A cursor is a keyset position in
342
+ // the UNFILTERED row order; resuming a client-side-filtered read from one
343
+ // would start mid-way through a sequence this call never produced. The tool
344
+ // schema says `cursor` is ignored when `kind`/`host` is set, and this is what
345
+ // makes that true rather than merely aspirational.
346
+ const { cursor: _ignoredCursor, ...rest } = a;
347
+ const raw = await store.list({ ...rest, limit: widened });
348
+ const filtered = filterListTaxonomy(raw, a);
349
+ if (!filtered?.ok || !Array.isArray(filtered.entries)) return projectListView(filtered, a.view);
350
+
351
+ const page = filtered.entries.slice(0, requested);
352
+ // `hasMore` is true when this page was cut — either by our own slice, or
353
+ // because the widened fetch itself saturated and rows beyond it were never
354
+ // examined. Preserve an upstream `hasMore` too; the remote store sets it.
355
+ const truncated =
356
+ filtered.entries.length > requested ||
357
+ (Array.isArray(raw?.entries) && raw.entries.length >= widened);
358
+
359
+ // `nextCursor` MUST be null on a taxonomy-filtered read, never the upstream
360
+ // cursor. That cursor is a keyset position in the UNFILTERED row order, taken
361
+ // from the end of the WIDENED fetch — so handing it back after returning only
362
+ // `requested` post-filter rows would make the next page resume past every row
363
+ // between the slice and the widened window, silently skipping matches.
364
+ //
365
+ // There is no correct cursor to synthesise here: the filter is applied client
366
+ // side, so no server-side keyset describes "the next filtered row". A filtered
367
+ // list is therefore a single bounded page, exactly as `order: "rank"` is on
368
+ // the edge — `hasMore` reports that it was cut, and the remedy is a larger
369
+ // `limit`, not pagination.
370
+ return projectListView(
371
+ {
372
+ ...filtered,
373
+ entries: page,
374
+ hasMore: Boolean(raw?.hasMore) || truncated,
375
+ nextCursor: null,
376
+ },
377
+ a.view,
378
+ );
379
+ }
380
+
216
381
  // tool name → (store, args, ctx) → store result. The store destructures the
217
382
  // args it needs, so the raw `arguments` object is passed straight through.
218
383
  // `ctx.root` is the resolved project root (`--dir`), NOT the process cwd — an
@@ -223,7 +388,13 @@ const MEMORY_DISPATCH = {
223
388
  // with anything the caller DID pass taking precedence.
224
389
  'memory.write': (store, a, ctx) => store.write({ ...a, ...withDerivedOrigin(a, ctx) }),
225
390
  'memory.read': (store, a) => store.read(a),
226
- 'memory.list': (store, a) => store.list(a),
391
+ // `view` is projected and `kind`/`host` post-filtered client-side rather than
392
+ // forwarded. The remote store reads `GET /memories`, which has no `view`
393
+ // parameter — only the MCP tool does — and the local store has neither the
394
+ // parameter nor the columns. Doing the work here keeps the stdio server's
395
+ // contract identical to the hosted one on both store backends, which is the
396
+ // whole point of `MEMORY_TOOL_DEFS` mirroring the catalog.
397
+ 'memory.list': (store, a) => listWithFilters(store, a),
227
398
  'memory.search': (store, a) => store.search(a),
228
399
  'memory.delete': (store, a) => store.delete(a),
229
400
  'memory.archive': (store, a) => store.archive(a),
@@ -248,7 +419,7 @@ const MEMORY_DISPATCH = {
248
419
  // networkError, unusable }`. A tool that passed either through verbatim would
249
420
  // hand the model two different contracts for one tool name depending on a
250
421
  // 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.
422
+ // owns: the count-desc-then-scope-asc sort and the exit-clean degradation below.
252
423
  //
253
424
  // DEGRADATION IS EXIT-CLEAN, mirroring the `scopes` command, which reports an
254
425
  // unreachable remote as a short note at exit 0 rather than failing the run. An
@@ -270,31 +441,31 @@ export async function listScopes(store) {
270
441
  return ok ? { ok: true, scopes: sortScopes(scopes) } : { ok: true, scopes: [], note: reason };
271
442
  }
272
443
 
273
- // Sorted by scope ascending, which is the contract `docs/mcp-tools.md`, the
274
- // tool catalog and `llms.txt` all state for `memory.scopes`. The HOSTED surface
275
- // gets that ordering from `lorekit_memory_scopes` (`order by m.scope asc`,
276
- // migration 00039/00049), but `LocalStore`/`TwoTierStore.listScopes()` both
277
- // return their `Map` insertion order — a walk order, not an ordering — so the
278
- // stdio server owns it here rather than the two surfaces answering differently.
279
- // Sorting BOTH shapes (not just the local one) makes the guarantee a property
280
- // of this function instead of an assumption about the store it was handed.
281
- // Codepoint comparison, deliberately not `localeCompare`: the ordering must not
282
- // depend on the HOST's locale.
444
+ // Sorted by count DESC then scope asc, which is the contract `docs/mcp-tools.md`,
445
+ // the tool catalog and `llms.txt` all state for `memory.scopes`. The HOSTED
446
+ // surface gets that ordering from `lorekit_memory_scopes` (`order by count(*)
447
+ // desc, m.scope asc`, migration 00065), but `LocalStore`/`TwoTierStore.
448
+ // listScopes()` both return their `Map` insertion order — a walk order, not an
449
+ // ordering — so the stdio server owns it here rather than the two surfaces
450
+ // answering differently. Sorting BOTH shapes (not just the local one) makes the
451
+ // guarantee a property of this function instead of an assumption about the store
452
+ // it was handed.
283
453
  //
284
- // That is ascending-by-scope, not byte-identical parity with the hosted path,
285
- // and the difference is worth being precise about. `order by m.scope asc` sorts
286
- // under the DATABASE's collation (`en_US.UTF-8` on a default Supabase project),
287
- // which does not order like codepoint around punctuation and a scope string
288
- // is mostly punctuation (`::`, `/`, `-`), so `repo::a-b` and `repo::ab` can come
289
- // out in the opposite relative order on the two surfaces. Case cannot differ
290
- // (every scope segment is lowercased, see docs/scope-format.md). Closing the
291
- // remaining gap means `collate "C"` on the RPC's `order by`, which changes the
292
- // order `GET /memories/scopes` has always returned a public contract change
293
- // that belongs in its own migration, not here. Until then: both surfaces are
294
- // sorted ascending, neither is unordered, and nothing should depend on the two
295
- // agreeing on the exact position of a punctuated neighbour.
454
+ // The primary key is `count` (a number), which orders identically on both
455
+ // surfaces. Only the scope-asc TIEBREAK between equal-count scopes carries the
456
+ // old caveat: it is a codepoint comparison here (deliberately not
457
+ // `localeCompare`, so the ordering never depends on the HOST's locale), while
458
+ // `order by m.scope asc` sorts under the DATABASE's collation (`en_US.UTF-8` on a
459
+ // default Supabase project), which does not order like codepoint around
460
+ // punctuation — and a scope string is mostly punctuation (`::`, `/`, `-`), so
461
+ // two equal-count scopes like `repo::a-b` and `repo::ab` can come out in the
462
+ // opposite relative order on the two surfaces. Case cannot differ (every scope
463
+ // segment is lowercased, see docs/scope-format.md). Nothing should depend on the
464
+ // two agreeing on the exact position of a punctuated equal-count neighbour.
296
465
  function sortScopes(rows) {
297
- return rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
466
+ return rows.sort(
467
+ (a, b) => b.count - a.count || (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0),
468
+ );
298
469
  }
299
470
 
300
471
  // Provenance for a tool call: the caller's explicit values win, the working
@@ -411,7 +582,19 @@ export function createHandler(control, { root = process.cwd() } = {}) {
411
582
  const fn = MEMORY_DISPATCH[name];
412
583
  if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
413
584
 
414
- const result = await fn(store, args, { root });
585
+ // A rejected ARGUMENT is a tool-level failure, not a broken transport.
586
+ // Letting the throw escape would answer JSON-RPC -32603 "Internal error",
587
+ // which tells the model nothing and contradicts what `toolResult`
588
+ // documents; the edge returns a `UserInputError` payload for the same
589
+ // typo. Surface it as `{ ok: false, error }` so the model can correct
590
+ // itself. Only argument validation is caught here — a store failure
591
+ // already comes back as `ok: false` rather than throwing.
592
+ let result;
593
+ try {
594
+ result = await fn(store, args, { root });
595
+ } catch (e) {
596
+ return toolResult(id, { ok: false, error: (e && e.message) || 'invalid arguments' });
597
+ }
415
598
  return toolResult(id, result);
416
599
  }
417
600
 
@@ -257,7 +257,7 @@ class RemoteStore {
257
257
  // The `scopes` array is the SAME `[{ scope, count }]` inventory shape
258
258
  // `LocalStore.listScopes()` returns, so `scopes.mjs` feeds both through the
259
259
  // same pure `filterScopeInventory`/`summarizeScopeInventory` helpers. Ordering
260
- // is not relied upon (the server sorts by scope asc; the view re-sorts by
260
+ // is not relied upon (the server sorts by count desc; the view re-sorts by
261
261
  // scope type). Failures use this store's standard `{ ok:false, error,
262
262
  // networkError }` envelope so the caller can degrade gracefully.
263
263
  //