@lorekit/cli 1.68.0 → 1.70.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.
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
  import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
11
11
  import { normalizeCreatedAt } from './created-at.mjs';
12
12
  import { isLive, resolveExpiresAt } from './ttl.mjs';
13
- import { seenCountOf, withReadFields } from './entry-fields.mjs';
13
+ import { seenCountOf, withReadFields, updatedAtOf } from './entry-fields.mjs';
14
+ import { pickScopeWinner } from '../shared/scope-precedence.mjs';
14
15
 
15
16
  export function createLocalStore(baseDir) {
16
17
  return new LocalStore(baseDir);
@@ -54,6 +55,20 @@ class LocalStore {
54
55
  return this._readAll(scope).find((r) => r.entry.key === key) || null;
55
56
  }
56
57
 
58
+ // Every LIVE entry carrying `key`, across every scope in this store — the
59
+ // candidate set for a read that named no scope.
60
+ //
61
+ // Walks the whole tree rather than one scope directory, because `_files`
62
+ // resolves a single scope→directory and there is no directory for "any".
63
+ // That is more IO than a scoped read, which is exactly why a caller that
64
+ // knows its scope should still pass it.
65
+ _findAllByKey(key) {
66
+ const now = new Date();
67
+ return this._walkEntries()
68
+ .map((r) => r.entry)
69
+ .filter((e) => e && e.key === key && e.scope && isLive(e, now));
70
+ }
71
+
57
72
  // Raw lookup by scope+key — returns the stored entry regardless of archived
58
73
  // state (unlike read(), which hides archived). Synchronous; used by migrate
59
74
  // to classify ADD / UPDATE / NOOP without reviving archived entries.
@@ -80,7 +95,14 @@ class LocalStore {
80
95
  }
81
96
 
82
97
  // read({ scope, key }) → { ok, entry } — null when absent, archived, or expired.
98
+ //
99
+ // `scope` is OPTIONAL. Without one the key is resolved across every scope in
100
+ // this store and the winner picked by the shared `scope-precedence` rule —
101
+ // the SAME rule the hosted MCP and REST surfaces apply, so `lorekit show
102
+ // <key>` offline and `memory.read { key }` online cannot resolve the same key
103
+ // to different lessons.
83
104
  async read({ scope, key } = {}) {
105
+ if (!scope) return { ok: true, entry: this._readUnscoped(key) };
84
106
  const found = this._findByKey(scope, key);
85
107
  return {
86
108
  ok: true,
@@ -88,6 +110,34 @@ class LocalStore {
88
110
  };
89
111
  }
90
112
 
113
+ // The unscoped half of `read`, split out so `TwoTierStore` can reuse the
114
+ // candidate gathering without re-deciding the precedence rule.
115
+ _readUnscoped(key) {
116
+ const winner = pickScopeWinner(this._findAllByKey(key).map(scopeCandidate));
117
+ return winner ? withReadFields(winner.entry) : null;
118
+ }
119
+
120
+ // readMany(refs) → { ok, entries, missing } — batch lookup for an array of
121
+ // `{ scope, key }` refs, mirroring the REST/MCP batch read's shape: found
122
+ // entries in `entries` (same projection `read()` applies), unresolved refs
123
+ // (absent, archived, or expired) reported as `scope::key` strings in
124
+ // `missing` rather than silently dropped. A thin loop over `_findByKey` —
125
+ // this store has no query engine to batch against, so there is no cheaper
126
+ // shape than one lookup per ref.
127
+ async readMany(refs = []) {
128
+ const entries = [];
129
+ const missing = [];
130
+ for (const { scope, key } of refs) {
131
+ const found = this._findByKey(scope, key);
132
+ if (found && isLive(found.entry)) {
133
+ entries.push(withReadFields(found.entry));
134
+ } else {
135
+ missing.push(`${scope}::${key}`);
136
+ }
137
+ }
138
+ return { ok: true, entries, missing };
139
+ }
140
+
91
141
  // write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
92
142
  // refreshes `updated`; writing an archived key revives it.
93
143
  //
@@ -328,6 +378,19 @@ class LocalStore {
328
378
  }
329
379
  }
330
380
 
381
+ /**
382
+ * Project a stored entry into the `{ scope, updated_at }` shape
383
+ * `scope-precedence` compares, keeping the entry itself alongside so the winner
384
+ * can be returned without a second lookup.
385
+ *
386
+ * `updatedAtOf` is what maps the local `updated` spelling onto the `updated_at`
387
+ * the shared rule expects — without it every local candidate would compare as
388
+ * having no timestamp and the tie-break would collapse to scope-ascending.
389
+ */
390
+ function scopeCandidate(entry) {
391
+ return { scope: entry.scope, updated_at: updatedAtOf(entry), entry };
392
+ }
393
+
331
394
  // A scope string is global when its type segment is `global`.
332
395
  function isGlobalScope(scope) {
333
396
  return String(scope).split('::')[0] === 'global';
@@ -394,6 +457,23 @@ class TwoTierStore {
394
457
  }
395
458
 
396
459
  async read({ scope, key } = {}) {
460
+ // Unscoped: precedence has to be decided over BOTH tiers at once, not tier
461
+ // by tier. Asking the project tier first and taking any hit would let a
462
+ // `global` lesson in the project tier beat a `repo::…` one in the home
463
+ // tier — tier order standing in for scope precedence, which is not what it
464
+ // means. Within ONE scope the project tier still shadows home, exactly as
465
+ // the scoped path below does.
466
+ if (!scope) {
467
+ const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
468
+ const byScope = new Map();
469
+ for (const tier of tiers) {
470
+ for (const entry of tier._findAllByKey(key)) {
471
+ if (!byScope.has(entry.scope)) byScope.set(entry.scope, entry);
472
+ }
473
+ }
474
+ const winner = pickScopeWinner([...byScope.values()].map(scopeCandidate));
475
+ return { ok: true, entry: winner ? withReadFields(winner.entry) : null };
476
+ }
397
477
  if (this.projectActive()) {
398
478
  const r = await this.project.read({ scope, key });
399
479
  if (r.entry) return r;
@@ -401,6 +481,20 @@ class TwoTierStore {
401
481
  return this.home.read({ scope, key });
402
482
  }
403
483
 
484
+ // readMany(refs) → { ok, entries, missing } — per-ref `read()`, so the same
485
+ // project-shadows-home precedence applies to every ref individually rather
486
+ // than to the batch as a whole.
487
+ async readMany(refs = []) {
488
+ const entries = [];
489
+ const missing = [];
490
+ for (const { scope, key } of refs) {
491
+ const r = await this.read({ scope, key });
492
+ if (r.entry) entries.push(r.entry);
493
+ else missing.push(`${scope}::${key}`);
494
+ }
495
+ return { ok: true, entries, missing };
496
+ }
497
+
404
498
  async write(args = {}) {
405
499
  return this.tierFor(args.scope).write(args);
406
500
  }
@@ -22,8 +22,19 @@ import { restFetch, mcpToRestBase } from '../shared/mcp.mjs';
22
22
  import { rememberAccountId } from '../telemetry/telemetry-identity.mjs';
23
23
  import { getActiveTraceparent } from '../telemetry/telemetry.mjs';
24
24
  import { withReadFields } from './entry-fields.mjs';
25
+ import { pickScopeWinner } from '../shared/scope-precedence.mjs';
25
26
  import { normalizeCreatedAt } from './created-at.mjs';
26
27
 
28
+ /**
29
+ * How many same-key rows an UNSCOPED read fetches before picking a winner.
30
+ *
31
+ * Deliberately the same number the hosted `memory.read` caps its own candidate
32
+ * fan-out at, so a key living in a pathological number of scopes truncates at
33
+ * the same point on both surfaces instead of the two considering different
34
+ * candidate sets and disagreeing about the winner.
35
+ */
36
+ const UNSCOPED_READ_CANDIDATE_LIMIT = 50;
37
+
27
38
  // Drop undefined/null args so JSON payloads stay tidy.
28
39
  function stripUndefined(obj) {
29
40
  const out = {};
@@ -249,12 +260,24 @@ class RemoteStore {
249
260
  };
250
261
  }
251
262
 
263
+ // read({ scope, key }) → { ok, entry } — `scope` is OPTIONAL.
264
+ //
265
+ // Without one the key can match once per scope holding it, so the precedence
266
+ // pick happens HERE rather than server-side: `GET /memories` is a LIST route,
267
+ // and narrowing it to a single winner would change what every other caller of
268
+ // it gets back. The rule is the shared `scope-precedence` one, so this
269
+ // resolves identically to MCP's `memory.read` (which picks server-side
270
+ // because it must answer with one lesson) and to the offline store.
252
271
  async read({ scope, key } = {}) {
253
272
  const p = new URLSearchParams();
254
273
  if (scope) p.set('scope', scope);
255
274
  if (key) p.set('key', key);
256
- // scope+key is unique, so one row is all there can be — don't pull the default page of 50.
257
- p.set('limit', '1');
275
+ // With a scope, `scope`+`key` is unique and one row is all there can be —
276
+ // don't pull the default page of 50. Without one, fetch the candidates the
277
+ // precedence pick needs; the cap matches the hosted read's own so the two
278
+ // surfaces truncate a pathological key at the same point rather than
279
+ // disagreeing about which lessons were even considered.
280
+ p.set('limit', scope ? '1' : String(UNSCOPED_READ_CANDIDATE_LIMIT));
258
281
  const res = await this._rest(`/memories?${p}`);
259
282
  // `unusable` is passed through: `_rest` short-circuits an unconfigured
260
283
  // store with that flag and NOTHING else, so a caller that drops it is left
@@ -275,9 +298,43 @@ class RemoteStore {
275
298
  };
276
299
  }
277
300
  const entries = res.data?.entries ?? [];
301
+ // `key` is an EXACT filter server-side, so every row here carries the key
302
+ // that was asked for; precedence only has to choose among their scopes. A
303
+ // scoped read has at most one candidate, so the pick is a no-op there.
304
+ const winner = pickScopeWinner(entries);
278
305
  // Same projection as list/search — a single read must not answer with a
279
306
  // different shape than the listing the caller found the key in.
280
- return { ok: true, entry: entries[0] ? withReadFields(entries[0]) : null };
307
+ return { ok: true, entry: winner ? withReadFields(winner) : null };
308
+ }
309
+
310
+ // readMany(refs) → { ok, entries, missing } — `POST /memories/read`, one
311
+ // round-trip for the whole batch (the server groups by scope and issues one
312
+ // `.in('key', …)` query per distinct scope — see `handlers/read.ts`). `refs`
313
+ // is an array of `{ scope, key }`; this transport joins each into the
314
+ // `scope::key` wire form the route's `ReadMemoriesBodySchema` expects. An
315
+ // empty/absent list short-circuits locally rather than making a request the
316
+ // server would just 400 on (`refs` has `.min(1)`).
317
+ async readMany(refs) {
318
+ const list = Array.isArray(refs) ? refs.filter(Boolean) : [];
319
+ if (!list.length) return { ok: true, entries: [], missing: [] };
320
+ const body = { refs: list.map((r) => `${r.scope}::${r.key}`) };
321
+ const res = await this._rest('/memories/read', { method: 'POST', body });
322
+ if (!res.ok) {
323
+ return {
324
+ ok: false,
325
+ error: res.error ?? null,
326
+ httpStatus: res.httpStatus ?? null,
327
+ retryAfter: res.retryAfter ?? null,
328
+ networkError: res.networkError ?? null,
329
+ unusable: res.unusable ?? false,
330
+ };
331
+ }
332
+ const data = res.data ?? {};
333
+ return {
334
+ ok: true,
335
+ entries: (data.entries ?? []).map(withReadFields),
336
+ missing: data.missing ?? [],
337
+ };
281
338
  }
282
339
 
283
340
  async write(args = {}) {
@@ -160,37 +160,37 @@ export const MCP_TOOL_DEFS = [
160
160
  },
161
161
  {
162
162
  "name": "memory.read",
163
- "description": "Read a lesson by scope and key",
163
+ "description": "Read one lesson by `key`, or several at once by `refs`. Pass exactly one of those two shapes: `key` (optionally narrowed with `scope`), or `refs` alone — a call carrying both is rejected, and so is one carrying neither. `scope` is optional: omit it and the key is resolved across every scope you can see, preferring the most specific one.",
164
164
  "inputSchema": {
165
165
  "type": "object",
166
- "required": [
167
- "scope",
168
- "key"
169
- ],
170
166
  "properties": {
171
167
  "scope": {
172
168
  "type": "string",
173
- "description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
169
+ "description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see."
174
170
  },
175
171
  "key": {
176
172
  "type": "string",
177
173
  "description": "Lesson identifier, unique within the scope. Max 512 characters."
174
+ },
175
+ "refs": {
176
+ "type": "array",
177
+ "items": {
178
+ "type": "string"
179
+ },
180
+ "description": "Batch mode: one or more `scope::key` references, fetched in a single call. Cannot be combined with `scope`/`key`. Each entry is parsed by the same reference grammar `memory.write`'s `cited` field uses (`scope::key`, verbatim scope — never lowercased). Silently truncated past 32 entries."
178
181
  }
179
182
  }
180
183
  }
181
184
  },
182
185
  {
183
186
  "name": "memory.list",
184
- "description": "List lessons for a scope",
187
+ "description": "List lessons, for one scope or across every scope you can see",
185
188
  "inputSchema": {
186
189
  "type": "object",
187
- "required": [
188
- "scope"
189
- ],
190
190
  "properties": {
191
191
  "scope": {
192
192
  "type": "string",
193
- "description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
193
+ "description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see."
194
194
  },
195
195
  "tags": {
196
196
  "type": "array",
@@ -338,7 +338,7 @@ export const MCP_TOOL_DEFS = [
338
338
  },
339
339
  {
340
340
  "name": "memory.scopes",
341
- "description": "List every scope in the store with how many active memories it holds and when it was last written to — the inventory to consult when you do not already know which scope to read. Takes no arguments and is store-wide, NOT limited to any working directory. Every other read tool requires a scope up front, so this is the one that answers \"what is there?\".",
341
+ "description": "List every scope in the store with how many active memories it holds and when it was last written to — the inventory to consult when you do not already know which scope to read. Takes no arguments and is store-wide, NOT limited to any working directory. The other read tools answer questions about lore; this one answers \"what is there?\" — reach for it when you want to NAME a scope, to narrow a list or to decide where a write belongs.",
342
342
  "inputSchema": {
343
343
  "type": "object",
344
344
  "properties": {}
@@ -346,16 +346,13 @@ export const MCP_TOOL_DEFS = [
346
346
  },
347
347
  {
348
348
  "name": "memory.list_archived",
349
- "description": "List archived (soft-deleted) lessons for a scope",
349
+ "description": "List archived (soft-deleted) lessons, for one scope or across every scope you can see",
350
350
  "inputSchema": {
351
351
  "type": "object",
352
- "required": [
353
- "scope"
354
- ],
355
352
  "properties": {
356
353
  "scope": {
357
354
  "type": "string",
358
- "description": "Canonical scope string, e.g. `repo::mthines/lorekit`."
355
+ "description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see."
359
356
  },
360
357
  "limit": {
361
358
  "type": "integer",