@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 +1 -1
- package/src/mcp-server.mjs +120 -0
- package/src/store/remote.mjs +34 -2
package/package.json
CHANGED
package/src/mcp-server.mjs
CHANGED
|
@@ -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.
|
package/src/store/remote.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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?
|