@lorekit/cli 1.39.0 → 1.39.2
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/core/lessons.mjs +109 -5
- package/src/mcp-server.mjs +20 -60
- package/src/store/remote.mjs +2 -2
- package/src/store/scope-inventory.mjs +123 -0
- package/src/telemetry.mjs +80 -7
package/package.json
CHANGED
package/src/core/lessons.mjs
CHANGED
|
@@ -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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
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.
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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
|
|
243
|
-
// job
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
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.
|
package/src/store/remote.mjs
CHANGED
|
@@ -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` — `
|
|
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
|
+
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -39,6 +39,9 @@ const DEFAULT_DATASET = 'default';
|
|
|
39
39
|
// Flags worth counting (e.g. how many installs are --global). Bounded on
|
|
40
40
|
// purpose: only these booleans are ever attached, never free-form values.
|
|
41
41
|
const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json', 'link'];
|
|
42
|
+
// One definition, used both to WRITE a flag attribute and to recognise one as
|
|
43
|
+
// reserved in `commandAttributes` — the two must not be able to drift.
|
|
44
|
+
const FLAG_ATTR_PREFIX = 'lorekit.cli.flag.';
|
|
42
45
|
|
|
43
46
|
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
|
44
47
|
|
|
@@ -245,17 +248,87 @@ function resourceAttributes(version, env = process.env) {
|
|
|
245
248
|
|
|
246
249
|
// ── Payload builders (pure — unit-tested) ─────────────────────────────────────
|
|
247
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The CLOSED vocabulary of `lorekit.cli.outcome`, and the one place it is
|
|
253
|
+
* written down in code.
|
|
254
|
+
*
|
|
255
|
+
* The three values are not synonyms and the distinction is load-bearing:
|
|
256
|
+
*
|
|
257
|
+
* - `ok` — ran, exit 0.
|
|
258
|
+
* - `failure` — RAN TO COMPLETION and reported a negative VERDICT (a failing
|
|
259
|
+
* `doctor` check, a `lint` finding). The command did its job.
|
|
260
|
+
* - `error` — CRASHED. This is the only one that also sets the span status to
|
|
261
|
+
* `STATUS_CODE_ERROR`.
|
|
262
|
+
*
|
|
263
|
+
* That is what keeps the `cli` service's error rate a measure of the CLI being
|
|
264
|
+
* broken rather than of the user's environment being unhealthy (see the note
|
|
265
|
+
* above the non-zero-exit branch in {@link traceCommand}).
|
|
266
|
+
*
|
|
267
|
+
* WHY A FROZEN CONSTANT RATHER THAN THREE STRING LITERALS. The values were only
|
|
268
|
+
* ever written inline, so the vocabulary was discoverable from the emitted
|
|
269
|
+
* telemetry and nowhere else — and read from telemetry alone the distinction is
|
|
270
|
+
* genuinely easy to misread. A `doctor` that CRASHED in one release and FAILED
|
|
271
|
+
* GRACEFULLY in the next shows up as `error` then `failure` for the same
|
|
272
|
+
* user-visible symptom, which reads like the attribute drifting when it is
|
|
273
|
+
* actually the CLI getting better. Naming the set makes the difference legible
|
|
274
|
+
* at the call site and gives `telemetry.test.mjs` something to pin the docs to.
|
|
275
|
+
*/
|
|
276
|
+
export const CLI_OUTCOMES = Object.freeze({
|
|
277
|
+
OK: 'ok',
|
|
278
|
+
FAILURE: 'failure',
|
|
279
|
+
ERROR: 'error',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/** The same vocabulary as a value list, for guards and exhaustiveness checks. */
|
|
283
|
+
export const CLI_OUTCOME_VALUES = Object.freeze(Object.values(CLI_OUTCOMES));
|
|
284
|
+
|
|
285
|
+
/** The attribute keys `commandAttributes` owns — see its docblock below. */
|
|
286
|
+
const isReservedAttr = (key) =>
|
|
287
|
+
key === 'lorekit.cli.command' ||
|
|
288
|
+
key === 'lorekit.cli.outcome' ||
|
|
289
|
+
key === 'lorekit.cli.exit_code' ||
|
|
290
|
+
key.startsWith(FLAG_ATTR_PREFIX);
|
|
291
|
+
|
|
248
292
|
/**
|
|
249
293
|
* Collect the bounded, non-PII attributes for a command invocation. Only the
|
|
250
294
|
* command name, allow-listed boolean flags, the outcome and the exit code.
|
|
295
|
+
*
|
|
296
|
+
* Deliberately does NOT validate `outcome`: this runs inside the `finally` of
|
|
297
|
+
* every traced command, where throwing would turn a telemetry problem into a
|
|
298
|
+
* command failure. The vocabulary is enforced at the call sites (all of which
|
|
299
|
+
* are in this file) and pinned by `telemetry.test.mjs`.
|
|
300
|
+
*
|
|
301
|
+
* The keys this function owns — command, outcome, exit code, flags — are a
|
|
302
|
+
* RESERVED NAMESPACE: an `extraAttrs` entry under one of them is dropped, and
|
|
303
|
+
* the owned value (if any) is written afterwards. `extraAttrs` used to be
|
|
304
|
+
* merged over last, which meant a command returning
|
|
305
|
+
* `{ exitCode, 'lorekit.cli.outcome': … }` silently replaced the frozen value on
|
|
306
|
+
* its way out — a runtime path the source scan cannot see, because it proves
|
|
307
|
+
* the literal at the call site and not that the value reaches the wire.
|
|
308
|
+
*
|
|
309
|
+
* Reserving the NAMESPACE rather than just overwriting key by key matters
|
|
310
|
+
* because two of the owned keys are written conditionally: `exit_code` only
|
|
311
|
+
* when `exitCode` is a number, and each flag only when it is truthy. Overwriting
|
|
312
|
+
* alone therefore left the gap open in exactly the cases where the CLI emits
|
|
313
|
+
* nothing — an extras value would have been the only `lorekit.cli.exit_code` on
|
|
314
|
+
* the span, sourced from the command rather than from here.
|
|
315
|
+
*
|
|
316
|
+
* A collision is dropped, not rejected: this runs inside the `finally` of every
|
|
317
|
+
* traced command, where throwing would turn a telemetry problem into a command
|
|
318
|
+
* failure. Losing a datum a command should not have put there is the smaller
|
|
319
|
+
* harm than emitting an unowned value under an owned key.
|
|
251
320
|
*/
|
|
252
321
|
export function commandAttributes({ command, args = {}, outcome, exitCode, extraAttrs = {} }) {
|
|
253
|
-
const attrs = {
|
|
322
|
+
const attrs = {};
|
|
323
|
+
for (const [key, value] of Object.entries(extraAttrs)) {
|
|
324
|
+
if (!isReservedAttr(key)) attrs[key] = value;
|
|
325
|
+
}
|
|
326
|
+
attrs['lorekit.cli.command'] = command;
|
|
327
|
+
attrs['lorekit.cli.outcome'] = outcome;
|
|
254
328
|
if (typeof exitCode === 'number') attrs['lorekit.cli.exit_code'] = exitCode;
|
|
255
329
|
for (const flag of FLAG_ATTRS) {
|
|
256
|
-
if (args[flag]) attrs[
|
|
330
|
+
if (args[flag]) attrs[`${FLAG_ATTR_PREFIX}${flag}`] = true;
|
|
257
331
|
}
|
|
258
|
-
Object.assign(attrs, extraAttrs);
|
|
259
332
|
return attrs;
|
|
260
333
|
}
|
|
261
334
|
|
|
@@ -389,7 +462,7 @@ export async function probeTelemetryExport(config, { version = '0.0.0', timeoutM
|
|
|
389
462
|
name: 'lorekit.cli.doctor.telemetry_probe',
|
|
390
463
|
attributes: {
|
|
391
464
|
'lorekit.cli.command': 'doctor',
|
|
392
|
-
'lorekit.cli.outcome':
|
|
465
|
+
'lorekit.cli.outcome': CLI_OUTCOMES.OK,
|
|
393
466
|
'lorekit.telemetry.probe': true,
|
|
394
467
|
},
|
|
395
468
|
startMs: now,
|
|
@@ -533,7 +606,7 @@ export async function traceCommand(command, args, version, run) {
|
|
|
533
606
|
// `outcome` is the command's VERDICT (ok | failure | error); `status` is the
|
|
534
607
|
// SPAN status, and only a crash sets it to error. See the note above the
|
|
535
608
|
// non-zero-exit branch below.
|
|
536
|
-
let outcome =
|
|
609
|
+
let outcome = CLI_OUTCOMES.OK;
|
|
537
610
|
let status = 'ok';
|
|
538
611
|
let statusMessage;
|
|
539
612
|
let extraAttrs = {};
|
|
@@ -563,11 +636,11 @@ export async function traceCommand(command, args, version, run) {
|
|
|
563
636
|
// the CLI being broken rather than of the user's environment being
|
|
564
637
|
// unhealthy. Query the failure verdicts on those attributes, never on the
|
|
565
638
|
// span status.
|
|
566
|
-
outcome =
|
|
639
|
+
outcome = CLI_OUTCOMES.FAILURE;
|
|
567
640
|
}
|
|
568
641
|
return exitCode;
|
|
569
642
|
} catch (e) {
|
|
570
|
-
outcome =
|
|
643
|
+
outcome = CLI_OUTCOMES.ERROR;
|
|
571
644
|
status = 'error';
|
|
572
645
|
// Record only a bounded, non-PII identifier — NEVER e.message. Node fs /
|
|
573
646
|
// network error messages embed absolute paths (e.g. "ENOENT: ... open
|