@lorekit/cli 1.36.0 → 1.37.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.36.0",
3
+ "version": "1.37.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": {
@@ -146,6 +146,17 @@ export const MEMORY_TOOL_DEFS = [
146
146
  description: 'Soft-archive a memory. Hidden from reads but restorable.',
147
147
  inputSchema: { type: 'object', required: ['scope', 'key'] },
148
148
  },
149
+ {
150
+ name: 'memory.scopes',
151
+ // The description tells the model WHEN to reach for this, not just what it
152
+ // returns: every other read tool needs a scope named up front, so this is
153
+ // the one that answers "what is there?" before you can ask "what is in it?".
154
+ description:
155
+ 'List every scope in the store with how many active memories it holds — '
156
+ + 'the inventory to consult when you do not already know which scope to read. '
157
+ + 'Takes no arguments and is store-wide, NOT limited to the current directory.',
158
+ inputSchema: { type: 'object', properties: {} },
159
+ },
149
160
  ];
150
161
 
151
162
  // Org tools — always advertised regardless of memory mode. They always route
@@ -215,8 +226,117 @@ const MEMORY_DISPATCH = {
215
226
  'memory.search': (store, a) => store.search(a),
216
227
  'memory.delete': (store, a) => store.delete(a),
217
228
  'memory.archive': (store, a) => store.archive(a),
229
+ 'memory.scopes': (store) => listScopes(store),
218
230
  };
219
231
 
232
+ // `memory.scopes` — the store-wide inventory, normalised.
233
+ //
234
+ // This exists because an agent that cannot enumerate scopes cannot know what it
235
+ // does not know. `memory.list` and `memory.search` both need a scope (or a
236
+ // scope list) up front, so without this the only reachable lore is the lore
237
+ // whose scope the agent could already name — and the SessionStart injection is
238
+ // deliberately a bounded slice, not an index of the whole store. `GET
239
+ // /memories/scopes` and the `lorekit scopes` command have answered this since
240
+ // migration 00039; the MCP surface was the one caller that could not ask.
241
+ //
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.
249
+ //
250
+ // DEGRADATION IS EXIT-CLEAN, mirroring the `scopes` command, which reports an
251
+ // unreachable remote as a short note at exit 0 rather than failing the run. An
252
+ // inventory that cannot be built is `{ scopes: [], note }` with `ok: true`, so
253
+ // `toolResult` does NOT mark it `isError`: "I could not enumerate" is a fact
254
+ // about the store, not a failed tool call, and a model that receives a
255
+ // tool-level error is liable to retry it rather than carry on with the lore it
256
+ // can already reach. The note says which, in bounded, non-PII terms.
257
+ 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) };
275
+ }
276
+
277
+ // Sorted by scope ascending, which is the contract `docs/mcp-tools.md`, the
278
+ // tool catalog and `llms.txt` all state for `memory.scopes`. The HOSTED surface
279
+ // gets that ordering from `lorekit_memory_scopes` (`order by m.scope asc`,
280
+ // migration 00039/00049), but `LocalStore`/`TwoTierStore.listScopes()` both
281
+ // return their `Map` insertion order — a walk order, not an ordering — so the
282
+ // stdio server owns it here rather than the two surfaces answering differently.
283
+ // Sorting BOTH shapes (not just the local one) makes the guarantee a property
284
+ // of this function instead of an assumption about the store it was handed.
285
+ // Codepoint comparison, deliberately not `localeCompare`: the ordering must not
286
+ // depend on the HOST's locale.
287
+ //
288
+ // That is ascending-by-scope, not byte-identical parity with the hosted path,
289
+ // and the difference is worth being precise about. `order by m.scope asc` sorts
290
+ // under the DATABASE's collation (`en_US.UTF-8` on a default Supabase project),
291
+ // which does not order like codepoint around punctuation — and a scope string
292
+ // is mostly punctuation (`::`, `/`, `-`), so `repo::a-b` and `repo::ab` can come
293
+ // out in the opposite relative order on the two surfaces. Case cannot differ
294
+ // (every scope segment is lowercased, see docs/scope-format.md). Closing the
295
+ // remaining gap means `collate "C"` on the RPC's `order by`, which changes the
296
+ // order `GET /memories/scopes` has always returned — a public contract change
297
+ // that belongs in its own migration, not here. Until then: both surfaces are
298
+ // sorted ascending, neither is unordered, and nothing should depend on the two
299
+ // agreeing on the exact position of a punctuated neighbour.
300
+ function sortScopes(rows) {
301
+ return rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
302
+ }
303
+
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
+
220
340
  // Provenance for a tool call: the caller's explicit values win, the working
221
341
  // directory and CI environment fill the rest. Best-effort — a failure to shell
222
342
  // out to git must never fail the write, so it degrades to no origin at all.
@@ -217,11 +217,43 @@ class RemoteStore {
217
217
  // is not relied upon (the server sorts by scope asc; the view re-sorts by
218
218
  // scope type). Failures use this store's standard `{ ok:false, error,
219
219
  // networkError }` envelope so the caller can degrade gracefully.
220
+ //
221
+ // `httpStatus` is carried through VERBATIM from `restFetch`, which is the ONLY
222
+ // place the real status lives: its error object holds `{ message, code }`,
223
+ // where `code` is the response body's own application code on a JSON error
224
+ // (a string like `permission_denied`) and only incidentally the status on a
225
+ // non-JSON one. A consumer that wants to say "HTTP 403" must therefore read
226
+ // `httpStatus`, never `error.code` — `mcp-server.mjs`'s `scopeFailureNote`
227
+ // does exactly that, and it had nothing to read until this field was passed
228
+ // through. Additive: `scopes.mjs`, `stats.mjs` and `lessons-view.mjs` all
229
+ // branch on `ok` / `unusable` / `networkError` and ignore the extra key.
220
230
  async listScopes() {
221
231
  const res = await this._rest('/memories/scopes');
222
- if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError, unusable: res.unusable };
232
+ if (!res.ok) {
233
+ return {
234
+ ok: false,
235
+ error: res.error,
236
+ httpStatus: res.httpStatus,
237
+ networkError: res.networkError,
238
+ unusable: res.unusable,
239
+ };
240
+ }
223
241
  const scopes = Array.isArray(res.data?.scopes) ? res.data.scopes : [];
224
- return { ok: true, scopes: scopes.map((s) => ({ scope: s.scope, count: Number(s.count) || 0 })) };
242
+ // `last_activity` (migration 00049) is `max(created_at)` over exactly the
243
+ // counted rows — per-scope freshness without listing rows to reduce them,
244
+ // which is the row-cap trap this endpoint exists to avoid. It is passed
245
+ // through when present and OMITTED when absent (an older backend, or the
246
+ // offline store, which has no equivalent), so a consumer can tell "this
247
+ // store does not report freshness" from "this scope has none". Callers that
248
+ // read only `{ scope, count }` — `scopes.mjs`, `stats.mjs` — are unaffected.
249
+ return {
250
+ ok: true,
251
+ scopes: scopes.map((s) => ({
252
+ scope: s.scope,
253
+ count: Number(s.count) || 0,
254
+ ...(s.last_activity ? { last_activity: s.last_activity } : {}),
255
+ })),
256
+ };
225
257
  }
226
258
 
227
259
  // Authentication probe for doctor — does the configured token STILL work?