@lorekit/cli 1.50.0 → 1.51.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.50.0",
3
+ "version": "1.51.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": {
package/src/mcp.mjs CHANGED
@@ -158,6 +158,30 @@ export function normalizeRunEnvironment(raw) {
158
158
  return /^[A-Za-z0-9_.\-:]+$/.test(t) ? t : null;
159
159
  }
160
160
 
161
+ /**
162
+ * The retry delay a failed response advertised, in whole seconds, or null.
163
+ *
164
+ * Prefers the JSON body's `retryAfterSeconds` over the `Retry-After` header:
165
+ * both are set by the same `tooManyRequests()` helper, and the body value is
166
+ * the number the rate-limit RPC actually returned, while the header is its
167
+ * stringified copy that an intermediary may rewrite.
168
+ *
169
+ * TOTAL over any input. `headers` is read through optional calls because a
170
+ * test double (and a hand-rolled Response-alike) may not implement the Headers
171
+ * interface, and a missing retry hint must never be able to throw on an error
172
+ * path — the caller is already handling a failure.
173
+ */
174
+ export function retryAfterFrom(data, headers) {
175
+ const raw = data?.retryAfterSeconds ?? (typeof headers?.get === 'function' ? headers.get('retry-after') : null);
176
+ if (raw == null || raw === '') return null;
177
+ const n = Number(raw);
178
+ // A `Retry-After` may also be an HTTP-date; a non-numeric value is reported
179
+ // as "no hint" so the caller falls back to its own backoff rather than
180
+ // waiting on NaN.
181
+ if (!Number.isFinite(n) || n < 0) return null;
182
+ return Math.ceil(n);
183
+ }
184
+
161
185
  export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
162
186
  const controller = new AbortController();
163
187
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -203,6 +227,12 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
203
227
  return {
204
228
  ok: false,
205
229
  httpStatus: res.status,
230
+ // How long the server asked the caller to wait, in seconds, or null when
231
+ // it did not say. Only a 429 carries one today (`tooManyRequests` sets
232
+ // BOTH a `retryAfterSeconds` body field and the `Retry-After` header),
233
+ // but this is read on every failure so a future 503 needs no change
234
+ // here. Additive: existing callers ignore the extra key.
235
+ retryAfter: retryAfterFrom(data, res.headers),
206
236
  error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
207
237
  };
208
238
  }
@@ -29,7 +29,7 @@ export const FIELDS = [
29
29
  // columns: a file written before this existed simply decodes it as absent.
30
30
  'expires_at',
31
31
  // Recurrence — how many times this lesson has been written, mirroring the
32
- // hosted `memories.seen_count` column (migration 00058) so an offline store
32
+ // hosted `memories.seen_count` column (migration 00059) so an offline store
33
33
  // carries the same salience signal a remote one does. Appended like the
34
34
  // columns above: a file written before this existed decodes it as absent,
35
35
  // which the read projection reports as 0 rather than inventing a count.
@@ -134,7 +134,7 @@ class LocalStore {
134
134
  archived_at: null,
135
135
  expires_at,
136
136
  // Recurrence, counted the way the hosted `memory_write` RPC counts it
137
- // (migration 00058): a write against a key this store already holds IS
137
+ // (migration 00059): a write against a key this store already holds IS
138
138
  // the next sighting. `seenCountOf` floors an absent/hand-edited value to
139
139
  // 0, so a file written before this column existed resumes at 1 on its
140
140
  // next write rather than throwing or restarting the tally at 2.
@@ -21,6 +21,7 @@
21
21
  import { restFetch, mcpToRestBase } from '../mcp.mjs';
22
22
  import { getActiveTraceparent } from '../telemetry.mjs';
23
23
  import { withReadFields } from './entry-fields.mjs';
24
+ import { normalizeCreatedAt } from './created-at.mjs';
24
25
 
25
26
  // Drop undefined/null args so JSON payloads stay tidy.
26
27
  function stripUndefined(obj) {
@@ -29,6 +30,55 @@ function stripUndefined(obj) {
29
30
  return out;
30
31
  }
31
32
 
33
+ /**
34
+ * A read that could not be answered — a transport failure or a non-2xx status,
35
+ * as opposed to "the lesson is not there".
36
+ *
37
+ * Exported so a caller can tell it from a programming error and degrade
38
+ * per-entry (report this one, keep going) instead of aborting a whole run.
39
+ * `result` carries the raw store envelope for the message the caller shows.
40
+ */
41
+ export class StoreReadError extends Error {
42
+ constructor(message, result) {
43
+ super(message);
44
+ this.name = 'StoreReadError';
45
+ this.result = result;
46
+ }
47
+ }
48
+
49
+ // An absolute `expires_at` expressed as the hosted write's relative `ttl_days`.
50
+ //
51
+ // Three outcomes, and the third exists because "no expiry" and "I cannot tell"
52
+ // must not collapse into one answer:
53
+ //
54
+ // `undefined` no expiry — the caller states that positively with
55
+ // `clear_ttl: true`, so a permanent lesson stops being expiring.
56
+ // `'expired'` already elapsed; the caller must refuse (see `putEntry`).
57
+ // `'unknown'` an unparseable value. The caller then sends NEITHER TTL field,
58
+ // leaving the RPC on its `'keep'` branch, because the safe
59
+ // reading of a corrupt frontmatter field is "do not touch the
60
+ // expiry" — the same fail-safe posture as `isExpired`. Treating
61
+ // it as no expiry would let one bad character wipe a live remote
62
+ // TTL.
63
+ //
64
+ // else the remaining WHOLE days, clamped to the schema's 1–365.
65
+ function remoteTtlDays(expiresAt, now = new Date()) {
66
+ const exact = remoteTtlDaysExact(expiresAt, now);
67
+ if (typeof exact !== 'number') return exact;
68
+ return Math.min(365, Math.max(1, exact));
69
+ }
70
+
71
+ // The same conversion WITHOUT the 1–365 clamp, so a caller can see that the
72
+ // clamp bound and report the loss. Same three non-numeric outcomes.
73
+ function remoteTtlDaysExact(expiresAt, now = new Date()) {
74
+ if (!expiresAt) return undefined;
75
+ const ms = Date.parse(expiresAt);
76
+ if (Number.isNaN(ms)) return 'unknown';
77
+ const remaining = ms - now.getTime();
78
+ if (remaining <= 0) return 'expired';
79
+ return Math.ceil(remaining / 86_400_000);
80
+ }
81
+
32
82
  export function createRemoteStore({ endpoint, token } = {}) {
33
83
  return new RemoteStore(endpoint, token);
34
84
  }
@@ -151,7 +201,24 @@ class RemoteStore {
151
201
  // scope+key is unique, so one row is all there can be — don't pull the default page of 50.
152
202
  p.set('limit', '1');
153
203
  const res = await this._rest(`/memories?${p}`);
154
- if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
204
+ // `unusable` is passed through: `_rest` short-circuits an unconfigured
205
+ // store with that flag and NOTHING else, so a caller that drops it is left
206
+ // with a failure carrying no error and no networkError — a blank failure it
207
+ // can only report generically. Additive; every existing caller branches on
208
+ // `ok` and ignores the extra key.
209
+ if (!res.ok) {
210
+ return {
211
+ ok: false,
212
+ error: res.error ?? null,
213
+ // Carried for the same reason `write` carries them: a read can be
214
+ // rate-limited too, and a caller that retries needs to tell a 429 it
215
+ // should wait out from one it must not.
216
+ httpStatus: res.httpStatus ?? null,
217
+ retryAfter: res.retryAfter ?? null,
218
+ networkError: res.networkError ?? null,
219
+ unusable: res.unusable ?? false,
220
+ };
221
+ }
155
222
  const entries = res.data?.entries ?? [];
156
223
  // Same projection as list/search — a single read must not answer with a
157
224
  // different shape than the listing the caller found the key in.
@@ -181,7 +248,223 @@ class RemoteStore {
181
248
  if (origin_commit !== undefined) body.origin_commit = origin_commit;
182
249
  if (origin_pr !== undefined) body.origin_pr = origin_pr;
183
250
  const res = await this._rest('/memories', { method: 'POST', body });
184
- return { ok: res.ok, error: res.error, networkError: res.networkError };
251
+ // `httpStatus` and `retryAfter` are passed through so a caller can tell the
252
+ // two 429s apart and honour the server's own backoff. They are DIFFERENT
253
+ // failures wearing one status code: `code: 'rate_limited'` is transient and
254
+ // must be retried, `code: 'memory_cap'` is terminal (translateDbError maps
255
+ // the LK001 cap trigger to 429 as well) and must not be. Additive — the
256
+ // existing `{ ok, error, networkError }` keys are unchanged.
257
+ // Every field is coalesced, not just the retry hint: a caller comparing
258
+ // `httpStatus` must not get `null` from a refusal and `undefined` from the
259
+ // network-error or `unusable` branch, which is the exact split the shape
260
+ // exists to remove.
261
+ return {
262
+ ok: res.ok,
263
+ error: res.error ?? null,
264
+ httpStatus: res.httpStatus ?? null,
265
+ retryAfter: res.retryAfter ?? null,
266
+ networkError: res.networkError ?? null,
267
+ // `_rest` short-circuits an unconfigured store with `{ ok:false,
268
+ // unusable:true }` and no error at all, so without this the caller sees
269
+ // a failure with every field null and no reason. Passed through the way
270
+ // `listScopes` already does.
271
+ unusable: res.unusable ?? false,
272
+ };
273
+ }
274
+
275
+ // ── Migrate-destination parity with LocalStore ────────────────────────────
276
+ //
277
+ // `migrate` classifies each source entry ADD / UPDATE / NOOP with a read and
278
+ // then upserts it, against whatever store it was handed. LocalStore answers
279
+ // that with `getEntry` + `putEntry`; these are the remote halves, so the
280
+ // migrate loop stays ONE code path instead of branching per destination.
281
+ //
282
+ // The local pair is lossless (`putEntry` writes every field verbatim,
283
+ // archived rows included). The remote pair CANNOT be, because the hosted
284
+ // write is an RPC with a fixed parameter list, not a file write:
285
+ //
286
+ // preserved scope, key, source_agent, trigger, and `created` —
287
+ // sent as `created_at`, which memory_write honours on INSERT
288
+ // only, so a migrated lesson keeps its original creation date
289
+ // and its ranking recency with it. `value` survives too but is
290
+ // TRIMMED: `MemoryWriteSchema` applies `.transform(s =>
291
+ // s.trim())`, so surrounding whitespace does not make the trip.
292
+ // re-stamped `updated` — the server sets it to the write instant. There is
293
+ // no parameter for it, and inventing one would let a client
294
+ // backdate an edit it did not make.
295
+ // derived `seen_count` — the RPC owns the tally (migration 00059: a
296
+ // write against an existing key IS the next sighting). A lesson
297
+ // the hosted store has never seen lands at 1; one it already
298
+ // holds lands at ITS count plus one, not the local one. Either
299
+ // way the local history does not transfer.
300
+ // converted `expires_at` → `ttl_days`, the remaining whole days, clamped
301
+ // to the schema's 1–365 (a longer-lived TTL is clamped, not
302
+ // dropped — the alternative is silently making it permanent).
303
+ // A clamp IS lossy, so it is reported: the result carries
304
+ // `ttlClamped: true` and the caller can list the entry as
305
+ // shortened rather than leaving the user to discover it.
306
+ // A PERMANENT entry sends `clear_ttl: true` rather than simply
307
+ // omitting `ttl_days`: omission is the RPC's `'keep'` branch
308
+ // (migration 00031), which leaves an existing remote
309
+ // `expires_at` in place, so a permanent local lesson would
310
+ // land on an expiring remote row and still die.
311
+ // authoritative
312
+ // `tags`. The conflict clause is `tags = excluded.tags`, so the
313
+ // source entry's list REPLACES the hosted one — an untagged
314
+ // local entry sends `[]` and clears whatever labels the hosted
315
+ // row carried. That is what a verbatim upsert means here (the
316
+ // local file is the thing being migrated), but it is the one
317
+ // field where "verbatim" can remove hosted data, so it is
318
+ // called out rather than filed under preserved.
319
+ // sticky `origin_*`. The RPC's conflict clause coalesces provenance
320
+ // (`coalesce(excluded.origin_repo, memories.origin_repo)`, and
321
+ // likewise for branch/commit/pr) so a write that does not know
322
+ // a field cannot erase what an earlier one recorded. A migrated
323
+ // entry with NO provenance therefore leaves whatever the hosted
324
+ // row already had; it cannot clear it, by design, and there is
325
+ // no parameter that would. `source_agent` and `trigger` are NOT
326
+ // coalesced (`= excluded.*`), so an absent one still CLEARS the
327
+ // hosted value — not because a null is sent (`stripUndefined`
328
+ // drops nulls before the request) but because the REST handler
329
+ // substitutes `?? null` for the missing field and the RPC
330
+ // writes that. Omitted and null are the same instruction here,
331
+ // which is the opposite of what they mean for `origin_*`. `kind` and `host` are coalesced the same
332
+ // way, and this store never sends them at all — the server
333
+ // infers both from the `loop::` tag (`resolveKindHost`), which
334
+ // the tags carry, so a migrated lesson classifies itself.
335
+ //
336
+ // Two states have no remote representation at all and are REFUSED rather
337
+ // than silently rewritten, because writing them would resurrect a lesson the
338
+ // user retired: an archived entry (every conflict predicate on `memories` is
339
+ // partial on `archived_at is null`, so the hosted write does not revive the
340
+ // archived row — it INSERTS a second, live one beside it, leaving the store
341
+ // with both) and
342
+ // an already-expired one (any `ttl_days` re-dates it into the future). Both
343
+ // come back as `{ ok:false, unsupported }` so the caller can report them as
344
+ // skipped. `migrate` does NOT filter them today — adding that filter is the
345
+ // job of the PR that makes a remote destination reachable — so until then
346
+ // this refusal is the only thing standing between an archived lesson and
347
+ // resurrection, which is why it lives in the store and not in the caller.
348
+
349
+ // Raw lookup by scope+key, mirroring `LocalStore.getEntry` — the entry or
350
+ // null. The ROW is each store's own: this answers a REST `MemoryEntry`
351
+ // (`created_at`/`updated_at`) and the local one answers parsed frontmatter
352
+ // (`created`/`updated`), both through `withReadFields`. That is deliberate —
353
+ // the pair exists so a caller can ASK either store whether a key is there,
354
+ // not so it can compare two rows field-by-field without knowing which store
355
+ // produced them. A caller that compares has to speak the destination's
356
+ // spelling; that is what the remote comparison in the migrate loop does. LocalStore's is synchronous and this one cannot be, so callers must
357
+ // `await` it; awaiting the local store's plain return value is a no-op.
358
+ //
359
+ // One semantic difference the caller has to know about: LocalStore.getEntry
360
+ // sees archived rows and this cannot — `GET /memories` filters them out — so
361
+ // a remote destination classifies an archived counterpart as ADD, and the
362
+ // write then lands as a NEW live row beside the archived one (the conflict
363
+ // predicates are partial on `archived_at is null`). That is what the hosted
364
+ // `memory_write` does for any write against an archived key, not a
365
+ // migrate-specific quirk — and it is why `putEntry` refuses an archived
366
+ // source entry outright rather than relying on this classification.
367
+ //
368
+ // A FAILED read THROWS rather than answering null. Null is the answer to "no
369
+ // such lesson", and a caller that classifies ADD / UPDATE / NOOP acts on it:
370
+ // returning null for a transient 500 or a dropped connection would quietly
371
+ // reclassify an existing hosted lesson as new and overwrite it. The local
372
+ // store never throws here (a file read that fails is genuinely a miss), so
373
+ // this widens the contract only where the failure mode exists.
374
+ async getEntry({ scope, key } = {}) {
375
+ const res = await this.read({ scope, key });
376
+ if (!res.ok) {
377
+ // An unconfigured store carries no error at all, so name that case
378
+ // rather than reporting the generic failure text for it.
379
+ const reason = res.unusable
380
+ ? 'the remote store is not configured (missing endpoint or token)'
381
+ : (res.networkError || res.error?.message || 'read failed');
382
+ throw new StoreReadError(`remote read failed for ${scope}::${key}: ${reason}`, res);
383
+ }
384
+ return res.entry ?? null;
385
+ }
386
+
387
+ // Upsert one entry, as close to verbatim as the hosted write allows. See the
388
+ // fidelity table above for exactly which fields survive.
389
+ //
390
+ // EVERY branch answers with the SAME key set — `write`'s
391
+ // `{ ok, error, httpStatus, retryAfter, networkError, unusable }` plus
392
+ // Unlike `LocalStore.putEntry`, no `entry` comes back: the hosted write
393
+ // returns an id and derives the rest server-side, so echoing the request as
394
+ // if it were the stored row would be a fabrication. A caller that needs the
395
+ // stored row reads it back.
396
+ //
397
+ // `unsupported` (the refusal reason, else null), `ttlClamped` (the entry
398
+ // landed with a shortened life) and `createdAtDropped` (its `created` was
399
+ // unusable, so the server stamped the write instant instead). The last two
400
+ // report what HAPPENED, so both are false when the write did not succeed. A refusal fills the transport fields
401
+ // with null rather than omitting them, so a caller reading any one key never
402
+ // gets a value from one branch and `undefined` from another.
403
+ async putEntry(entry = {}, { now = new Date() } = {}) {
404
+ const refuse = (unsupported, message) => ({
405
+ ok: false,
406
+ unsupported,
407
+ // Carries a `code` like every real failure, so a caller can branch on
408
+ // one field instead of matching prose.
409
+ error: { message, code: 'unsupported' },
410
+ httpStatus: null,
411
+ retryAfter: null,
412
+ networkError: null,
413
+ // Every key any putEntry branch answers with, so a caller reading one
414
+ // never gets a value from one branch and `undefined` from another.
415
+ unusable: false,
416
+ ttlClamped: false,
417
+ createdAtDropped: false,
418
+ });
419
+ if (entry?.archived_at) {
420
+ return refuse('archived', 'archived entries cannot be written remotely — the hosted write would insert a second, live row beside the archived one');
421
+ }
422
+ const ttl = remoteTtlDays(entry?.expires_at, now);
423
+ if (ttl === 'expired') {
424
+ return refuse('expired', 'expired entries cannot be written remotely — any TTL would re-date them into the future');
425
+ }
426
+ // The same validation the server applies (`_shared/created-at.ts`, mirrored
427
+ // here), run BEFORE the request rather than discovered as a 400. A local
428
+ // file can hold a hand-edited or clock-skewed `created`, and losing the
429
+ // whole lesson over its creation date is the wrong trade: drop the
430
+ // override, let the server stamp now, and report the loss so the caller
431
+ // can say which entries were re-dated.
432
+ let createdAt;
433
+ let createdAtDropped = false;
434
+ try {
435
+ createdAt = normalizeCreatedAt(entry?.created ?? null, now) ?? undefined;
436
+ } catch {
437
+ createdAt = undefined;
438
+ createdAtDropped = Boolean(entry?.created);
439
+ }
440
+ const result = await this.write(stripUndefined({
441
+ scope: entry.scope,
442
+ key: entry.key,
443
+ value: entry.value == null ? '' : String(entry.value),
444
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
445
+ source_agent: entry.source_agent,
446
+ trigger: entry.trigger,
447
+ created_at: createdAt,
448
+ // `'unknown'` sends neither field, leaving the RPC on its `'keep'`
449
+ // branch. A real TTL sends only `ttl_days`; no TTL says so explicitly
450
+ // with `clear_ttl` rather than by omission — see the fidelity note above.
451
+ ttl_days: typeof ttl === 'number' ? ttl : undefined,
452
+ clear_ttl: ttl === undefined ? true : undefined,
453
+ origin_repo: entry.origin_repo,
454
+ origin_branch: entry.origin_branch,
455
+ origin_commit: entry.origin_commit,
456
+ origin_pr: entry.origin_pr,
457
+ }));
458
+ // Always present, like every other key in this envelope — a caller must
459
+ // not have to know which branch produced the result to read it.
460
+ // Both flags describe what HAPPENED, so they are false on a write that did
461
+ // not happen — a failed request shortened nothing and re-dated nothing.
462
+ return {
463
+ ...result,
464
+ unsupported: null,
465
+ ttlClamped: Boolean(result.ok) && ttl === 365 && remoteTtlDaysExact(entry?.expires_at, now) > 365,
466
+ createdAtDropped: Boolean(result.ok) && createdAtDropped,
467
+ };
185
468
  }
186
469
 
187
470
  // Natural-key DELETE. Without `force` the server soft-archives (stamps