@lorekit/cli 1.67.0 → 1.69.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.
@@ -14,6 +14,12 @@
14
14
  // split (the shorthand already carries a namespaced
15
15
  // key, since the split lands at the first valid-scope
16
16
  // prefix — see `resolveScopeArg`)
17
+ // show <s1::k1> <s2::k2> [...]
18
+ // — multi-ref form: two or more positionals that EACH
19
+ // parse as a complete `<scope>::<key>` reference (see
20
+ // `isMultiRefForm` below). Batches through each
21
+ // store's `readMany`, one round-trip to the remote
22
+ // store rather than one request per ref.
17
23
  //
18
24
  // Uses each store's real `read({scope, key})` method (both stores expose it),
19
25
  // not a filtered `list` — a single-record lookup is what `read` is for, and it
@@ -29,9 +35,12 @@ import {
29
35
  shortDate,
30
36
  describeError,
31
37
  recordsDiverge,
38
+ resolveScopeArg,
32
39
  resolveScopeKeyArgs,
40
+ isScopeString,
33
41
  scopeIssue,
34
42
  } from '../shared/lessons-view.mjs';
43
+ import { SCOPE_PRECEDENCE } from '../shared/scope-precedence.mjs';
35
44
  import { resolveAppBase } from '../shared/deeplink-pure.mjs';
36
45
  import { emitLink } from './link.mjs';
37
46
  import { log, err, heading, status, c } from '../shared/util.mjs';
@@ -55,6 +64,193 @@ async function readOne(store, scope, key) {
55
64
  return { available: true, found: Boolean(record), record, error: null };
56
65
  }
57
66
 
67
+ // Is this a multi-ref invocation? Two or more positionals, no `--scope`/`--key`
68
+ // override (a flag is an explicit single-ref assertion — see
69
+ // `resolveScopeKeyArgs`), and EVERY positional parses as a complete
70
+ // `<scope>::<key>` reference via `resolveScopeArg`.
71
+ //
72
+ // This is what keeps the existing `show <scope> <key>` two-positional form
73
+ // unambiguous: its first positional is a BARE scope with no `::`, so
74
+ // `resolveScopeArg` reports it with a null key and the predicate below is
75
+ // false — the single-ref path runs unchanged, exactly as `show <scope::key>`
76
+ // (one positional) always has.
77
+ function isMultiRefForm(positionals, args) {
78
+ if (positionals.length < 2 || args.scope || args.key) return false;
79
+ return positionals.every((p) => resolveScopeArg(p, isScopeString).key !== null);
80
+ }
81
+
82
+ // Batch-read `refs` ({scope,key}[]) from one store via its `readMany`, and
83
+ // project the result back into ONE `{available, found, record, error}` slot
84
+ // per ref, in the SAME order the refs were given — mirroring what `readOne`
85
+ // answers for a single ref, so the per-ref renderer and `--json` builder don't
86
+ // need to know whether they are looking at a single read or a batch one.
87
+ //
88
+ // A transport-level failure (throw, or `{ ok:false }`) degrades EVERY ref in
89
+ // the batch to the same error note, exactly as a per-store outage would if
90
+ // each ref were read one at a time — a batch call doesn't get to fail more
91
+ // silently than the single-ref path it replaces.
92
+ async function readManyFrom(store, refs) {
93
+ let res;
94
+ try {
95
+ res = await store.readMany(refs);
96
+ } catch (e) {
97
+ const msg = (e && e.message) || 'error';
98
+ return refs.map(() => ({ available: true, found: false, record: null, error: msg }));
99
+ }
100
+ if (!res || res.ok === false) {
101
+ const msg = describeError(res);
102
+ return refs.map(() => ({ available: true, found: false, record: null, error: msg }));
103
+ }
104
+ return projectBatchResult(refs, res);
105
+ }
106
+
107
+ /**
108
+ * The line printed under a ref no store returned a record for — `null` when the
109
+ * ref WAS found, since the caller only asks about unresolved ones. Pure and
110
+ * exported so the three answers below are unit-testable.
111
+ *
112
+ * Three answers, not two. A slot carrying an `error` NEVER LOOKED — either the
113
+ * store dropped the ref (`REF_DROPPED_ERROR`) or the read itself failed — so
114
+ * its silence is not a "not found", and lumping it in with a store that did
115
+ * look states as absent something nobody checked. The mixed case is the whole
116
+ * reason this is not a boolean: the local store answers every ref it is given
117
+ * and never truncates, so past the remote's 32-ref cap the offline store says
118
+ * "no" while the remote never looked, and only naming both halves is accurate.
119
+ */
120
+ export function unresolvedReason(ref, slots) {
121
+ const readable = slots.filter((s) => s.available);
122
+ const dropped = readable.filter((s) => Boolean(s.error));
123
+ const answered = readable.length - dropped.length;
124
+ if (answered === 0) return `no store looked up ${ref} — it may exist`;
125
+ if (dropped.length > 0) {
126
+ return `${ref} not found in the store(s) that looked; ${dropped.length} did not look — it may exist there`;
127
+ }
128
+ return `no memory found for ${ref} in the readable store(s)`;
129
+ }
130
+
131
+ /** The message a ref the store never looked up carries. Exported for the spec. */
132
+ export const REF_DROPPED_ERROR =
133
+ 'not looked up — the store dropped this reference (past the 32-reference cap, or a scope it does not accept)';
134
+
135
+ /**
136
+ * Project a `readMany` result back onto the requested refs, in request order.
137
+ * Pure — no store, no IO — so the three outcomes below are unit-testable
138
+ * without a mock REST server (see CLAUDE.md on the loopback-HTTP flakiness).
139
+ *
140
+ * A ref the store ANSWERED is in exactly one of `entries` or `missing`. One in
141
+ * NEITHER was never looked up: the remote store drops refs past the 32-reference
142
+ * cap and refs whose scope its stricter grammar rejects, and neither loss
143
+ * reaches `missing`, which is a not-found list by design. Reporting those as
144
+ * `found: false` would print "no such key in this store" for a lesson that may
145
+ * well be there, so they carry an explicit error instead. The local store
146
+ * answers every ref it is given, so that branch is unreachable for it by
147
+ * construction rather than by a transport check.
148
+ */
149
+ export function projectBatchResult(refs, res) {
150
+ const byRef = new Map();
151
+ for (const entry of res.entries || []) {
152
+ const record = normalizeEntry(entry);
153
+ byRef.set(`${record.scope}::${record.key}`, record);
154
+ }
155
+ const answered = new Set(res.missing || []);
156
+ return refs.map(({ scope, key }) => {
157
+ const ref = `${scope}::${key}`;
158
+ const record = byRef.get(ref) || null;
159
+ if (!record && !answered.has(ref)) {
160
+ return { available: true, found: false, record: null, error: REF_DROPPED_ERROR };
161
+ }
162
+ return { available: true, found: Boolean(record), record, error: null };
163
+ });
164
+ }
165
+
166
+ // The multi-ref path: resolve every ref against both stores (one `readMany`
167
+ // round-trip per store, not one per ref) and report each in request order.
168
+ async function showRefs(refs, args, root, env) {
169
+ const { local, remote, connection } = resolveStores(root, {
170
+ env,
171
+ endpoint: args.endpoint,
172
+ token: args.token,
173
+ });
174
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
175
+
176
+ const offlineList = localDenied
177
+ ? refs.map(() => ({ available: false, reason: `disabled by deny constraint (${localDenied.source})` }))
178
+ : await readManyFrom(local, refs);
179
+
180
+ const remoteAvailable = !remoteDenied && remote.usable();
181
+ const remoteList = remoteDenied
182
+ ? refs.map(() => ({ available: false, reason: `disabled by deny constraint (${remoteDenied.source})` }))
183
+ : remoteAvailable
184
+ ? await readManyFrom(remote, refs)
185
+ : refs.map(() => ({ available: false, reason: remoteUnavailableReason(connection) }));
186
+
187
+ const results = refs.map(({ scope, key }, i) => {
188
+ const offline = offlineList[i];
189
+ const remote_ = remoteList[i];
190
+ const foundOffline = Boolean(offline.available && offline.found);
191
+ const foundRemote = Boolean(remote_.available && remote_.found);
192
+ const diverged = foundOffline && foundRemote && recordsDiverge(offline.record, remote_.record);
193
+ const found = foundOffline || foundRemote;
194
+ // Which stores actually looked decides what a "not found" may claim.
195
+ const unresolved = found ? null : unresolvedReason(`${scope}::${key}`, [offline, remote_]);
196
+ const dropped = [offline, remote_].some((s) => s.available && Boolean(s.error));
197
+ return { scope, key, offline, remote_, foundOffline, foundRemote, diverged, found, unresolved, dropped };
198
+ });
199
+
200
+ if (args.json) {
201
+ log(JSON.stringify({
202
+ results: results.map((r) => buildJson({
203
+ scope: r.scope, key: r.key, offline: r.offline, remote_: r.remote_, diverged: r.diverged,
204
+ })),
205
+ }, null, 2));
206
+ } else {
207
+ heading('LoreKit memory');
208
+ log(` ${c.dim(`${results.length} references`)}`);
209
+ for (const r of results) {
210
+ log('');
211
+ log(` ${c.cyan(`${r.scope}::${r.key}`)}`);
212
+ renderRecordSection('Offline', r.offline);
213
+ renderRecordSection('Remote', r.remote_, remoteAvailable ? connection.endpoint : undefined);
214
+ if (r.diverged) status('warn', 'divergence', 'the offline and remote values differ');
215
+ // "not found" and "nobody looked" are different answers: say which.
216
+ if (r.unresolved) log(` ${c.dim(r.unresolved)}`);
217
+ }
218
+ log('');
219
+ }
220
+
221
+ const foundCount = results.filter((r) => r.found).length;
222
+ // Bounded, non-PII telemetry — counts only, never a scope or key string.
223
+ return {
224
+ exitCode: foundCount === results.length ? 0 : 1,
225
+ 'lorekit.cli.show.ref_count': results.length,
226
+ 'lorekit.cli.show.found_count': foundCount,
227
+ 'lorekit.cli.show.diverged_count': results.filter((r) => r.diverged).length,
228
+ // Refs at least one readable store never looked up — the batch hit the
229
+ // 32-ref cap, met a scope that store's grammar rejects, or the store
230
+ // errored. Without it a truncated batch is indistinguishable from a
231
+ // genuinely empty one: both report `found_count` below `ref_count`.
232
+ 'lorekit.cli.show.dropped_count': results.filter((r) => r.dropped).length,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Was this argument MEANT as a scope, even though it is not a valid one?
238
+ *
239
+ * The discriminator is the segment before the first `:` — if it is one of the
240
+ * four scope types, the caller was reaching for scope syntax and got it wrong
241
+ * (`global::`, `repo:noslash`, `branch::o/r`), and naming the scope is more
242
+ * useful than silently looking up a lesson keyed on that exact string.
243
+ *
244
+ * The case this must NOT catch is the reason the unscoped read exists:
245
+ * `pre-exec-lessons::automation-network-no-cli-blocks-everything` is a KEY that
246
+ * happens to contain `::`. Its first segment is not a scope type, so it reads
247
+ * as the key it is. A test for "does it contain `::`" would get that backwards.
248
+ */
249
+ function looksLikeScopeAttempt(token) {
250
+ const head = String(token).split(':')[0].toLowerCase();
251
+ return SCOPE_PRECEDENCE.includes(head);
252
+ }
253
+
58
254
  export async function show(args) {
59
255
  const root = resolveProjectRoot(args.dir);
60
256
  const env = { ...process.env };
@@ -67,10 +263,42 @@ export async function show(args) {
67
263
  // the `::` split (the shorthand handles a
68
264
  // namespaced key on its own now)
69
265
  const positionals = args._.slice(1);
70
- const { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
266
+
267
+ if (isMultiRefForm(positionals, args)) {
268
+ const refs = positionals.map((p) => resolveScopeArg(p, isScopeString));
269
+ return showRefs(refs, args, root, env);
270
+ }
271
+
272
+ let { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
71
273
  scope: args.scope,
72
274
  key: args.key,
73
275
  });
276
+ // A LONE positional that is neither a valid scope nor an ATTEMPT at one is a
277
+ // KEY, not a malformed scope.
278
+ //
279
+ // `resolveScopeKeyArgs` reports an unparseable first positional as the scope
280
+ // (with a null key) so its callers can name it as the malformed scope it is —
281
+ // right for `write`, which cannot proceed without knowing where to put the
282
+ // lesson. `show` can: a key alone resolves across every scope. So
283
+ // `lorekit show my-key` is a key-only lookup rather than "invalid scope
284
+ // my-key", which is what it used to say.
285
+ //
286
+ // Reinterpreted HERE rather than inside the shared parser: `write` and `link`
287
+ // must keep the strict reading, and the difference is about what each command
288
+ // can DO without a scope, not about how the arguments are spelled.
289
+ //
290
+ // Three things switch it off, each because the argument was plainly meant as
291
+ // a scope and a silent re-read would bury the user's actual mistake:
292
+ // - an explicit `--scope` (a flag is an assertion, so a bad one is an error)
293
+ // - MORE THAN ONE positional (`show foo bar` is the `<scope> <key>` form —
294
+ // `foo` is a scope that failed to parse, not a key with a stray argument)
295
+ // - a token that LOOKS like a scope attempt (`global::`, `repo:noslash`) —
296
+ // see `looksLikeScopeAttempt`.
297
+ if (!key && scope && !args.scope && positionals.length === 1
298
+ && !isScopeString(scope) && !looksLikeScopeAttempt(scope)) {
299
+ key = scope;
300
+ scope = '';
301
+ }
74
302
  // Scope validity is checked FIRST, for the same reason as in `write`: a bad
75
303
  // scope is the root cause, and "a key is required" is downstream noise.
76
304
  const badScope = scope ? scopeIssue(scope) : null;
@@ -80,10 +308,11 @@ export async function show(args) {
80
308
  err(`Run ${c.cyan('lorekit show --help')} for options.`);
81
309
  return 1;
82
310
  }
83
- if (!scope || !key) {
311
+ if (!key) {
84
312
  err(`${c.red('Usage:')} lorekit show <scope::key> [--json]`);
85
313
  err(` lorekit show <scope> <key> [--json]`);
86
- err(`Both a scope and a key are required. Run ${c.cyan('lorekit show --help')} for options.`);
314
+ err(` lorekit show <key> [--json] (searches every scope)`);
315
+ err(`A key is required. Run ${c.cyan('lorekit show --help')} for options.`);
87
316
  return 1;
88
317
  }
89
318
  // `show` consumes every positional it is given — unlike `write`, it has no
@@ -96,6 +325,18 @@ export async function show(args) {
96
325
  return 1;
97
326
  }
98
327
 
328
+ // `--link` is the one form that still needs a scope up front. It deliberately
329
+ // touches no store, and the deep link it prints is `?scope=…&lesson=…` — so
330
+ // without a scope there is nothing to resolve the key against and the URL
331
+ // would open the dashboard on an empty scope. Say so rather than emitting a
332
+ // link that goes nowhere.
333
+ if (args.link && !scope) {
334
+ err(`${c.red('Error:')} --link needs a scope — it builds a URL without reading any store.`);
335
+ err(`Pass ${c.cyan('lorekit show <scope>::' + key + ' --link')} or add ${c.cyan('--scope <s>')}.`);
336
+ err(`Run ${c.cyan('lorekit show ' + key)} to find which scope holds it.`);
337
+ return 1;
338
+ }
339
+
99
340
  // `--link` short-circuits: print the deep link that opens THIS lesson's detail
100
341
  // sheet (`?scope=…&lesson=…`) for the current args, without touching a store.
101
342
  if (args.link) {
@@ -44,6 +44,89 @@ export function parseMetaComment(value) {
44
44
  return out;
45
45
  }
46
46
 
47
+ const STATUS_TAG_PREFIX = 'status::';
48
+
49
+ /**
50
+ * A member's declared status. The canonical home is a `status::<value>` tag —
51
+ * first-class, filterable, and visible to a human reading the lesson — which is
52
+ * what the lorekit-setup skill now prescribes. A legacy `<!-- meta: status=… -->`
53
+ * comment in the body is still honoured so lessons written under the older
54
+ * convention keep their signal; the tag wins when both are present. Returns `''`
55
+ * when neither declares one.
56
+ */
57
+ export function statusOf(member) {
58
+ const tags = Array.isArray(member?.tags) ? member.tags : [];
59
+ for (const t of tags) {
60
+ if (typeof t === 'string' && t.startsWith(STATUS_TAG_PREFIX)) {
61
+ const v = t.slice(STATUS_TAG_PREFIX.length).trim();
62
+ if (v) return v;
63
+ }
64
+ }
65
+ const fromMeta = parseMetaComment(member?.value).status;
66
+ return typeof fromMeta === 'string' ? fromMeta.trim() : '';
67
+ }
68
+
69
+ // Is this line a bold LABEL, and which one? Returns `{ name, rest }` or null.
70
+ //
71
+ // Deliberately a line test rather than another alternative in a lookahead. Four
72
+ // separate defects in this function were one paragraph-terminating lookahead
73
+ // failing a different way each time — truncating at the first newline, then at
74
+ // any bold token, then failing to stop at a label whose colon sits OUTSIDE the
75
+ // bold (`**Why**:` rather than `**Why:**`). Each fix narrowed the failure
76
+ // without ending the class, because "where does this paragraph stop" is a
77
+ // line-level question and a single pattern answering it has to encode every
78
+ // spelling at once. Asked per line it is four lines of obvious code, and a new
79
+ // spelling is a new case here rather than a new branch inside a lookahead.
80
+ //
81
+ // A label carries a colon; bold alone does not. `**foo()** in the parser.` is
82
+ // prose continuing the paragraph, `**Why:**` and `**Why**:` both end it.
83
+ const BOLD_LEAD_RE = /^[ \t]*\*\*([^\n*]+?)\*\*[ \t]*(:?)[ \t]*/;
84
+
85
+ function boldLabel(line) {
86
+ const m = BOLD_LEAD_RE.exec(line);
87
+ if (!m) return null;
88
+ const [matched, inner, trailingColon] = m;
89
+ const innerColon = inner.endsWith(':');
90
+ if (trailingColon !== ':' && !innerColon) return null;
91
+ return {
92
+ name: (innerColon ? inner.slice(0, -1) : inner).trim().toLowerCase(),
93
+ rest: line.slice(matched.length),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * A member's applicability signal, printed verbatim and never interpreted. The
99
+ * canonical home is the visible `**Applies when:**` paragraph the lorekit-setup
100
+ * skill prescribes; a legacy `trigger-context` meta field is the fallback.
101
+ * Returns `''` when the lesson declares neither.
102
+ *
103
+ * Internal newlines are collapsed to single spaces — the only transformation,
104
+ * and a presentational one: the caller prints this as one field on one line, so
105
+ * a wrapped source paragraph must re-flow rather than break the record. No word
106
+ * is added, removed, or reordered, which is the sense in which it stays verbatim.
107
+ */
108
+ export function appliesWhenOf(member) {
109
+ const value = member?.value;
110
+ if (typeof value === 'string') {
111
+ const lines = value.split('\n');
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const label = boldLabel(lines[i]);
114
+ if (!label || label.name !== 'applies when') continue;
115
+ // The paragraph runs until a blank line, the next label, or the end.
116
+ const paragraph = [label.rest];
117
+ for (let j = i + 1; j < lines.length; j++) {
118
+ if (lines[j].trim() === '' || boldLabel(lines[j])) break;
119
+ paragraph.push(lines[j]);
120
+ }
121
+ const text = paragraph.join(' ').replace(/\s+/g, ' ').trim();
122
+ if (text) return text;
123
+ break; // declared but empty — fall through to the legacy field
124
+ }
125
+ }
126
+ const fromMeta = parseMetaComment(value)['trigger-context'];
127
+ return typeof fromMeta === 'string' ? fromMeta.trim() : '';
128
+ }
129
+
47
130
  function totalSeen(members) {
48
131
  return (members || []).reduce((n, m) => n + (Number.isFinite(m.seenCount) ? m.seenCount : 0), 0);
49
132
  }
@@ -57,14 +140,15 @@ function distinctScopeCount(members) {
57
140
  * summed `seenCount` across members crosses `minSeenCount` (default 3 — the
58
141
  * kickoff's "seen_count >= 3" criterion, applied to the SUM across the
59
142
  * cluster's members rather than any single one, since the whole pitch of a
60
- * candidate is "these N sightings are really one entry"), or a member's own
61
- * meta comment already declares a non-"active" status.
143
+ * candidate is "these N sightings are really one entry"), or a member already
144
+ * declares a non-"active" status (a `status::<value>` tag, or a legacy meta
145
+ * comment — see `statusOf`).
62
146
  */
63
147
  export function isCandidate(members, { minSeenCount = 3 } = {}) {
64
148
  if (totalSeen(members) >= minSeenCount) return true;
65
149
  return (members || []).some((m) => {
66
- const status = parseMetaComment(m.value).status;
67
- return typeof status === 'string' && status.length > 0 && status !== 'active';
150
+ const status = statusOf(m);
151
+ return status.length > 0 && status !== 'active';
68
152
  });
69
153
  }
70
154
 
@@ -90,7 +174,12 @@ export function rankCandidates(clusters, { minSeenCount = 3, resolveClass } = {}
90
174
  return (clusters || [])
91
175
  .filter((cl) => isCandidate(cl.members, { minSeenCount }))
92
176
  .map((cl) => {
93
- const members = (cl.members || []).map((m) => ({ ...m, meta: parseMetaComment(m.value) }));
177
+ const members = (cl.members || []).map((m) => ({
178
+ ...m,
179
+ meta: parseMetaComment(m.value),
180
+ status: statusOf(m),
181
+ appliesWhen: appliesWhenOf(m),
182
+ }));
94
183
  return {
95
184
  members,
96
185
  size: cl.size ?? members.length,
@@ -75,6 +75,11 @@ export const mirrorPairs = [
75
75
  { core: 'packages/mcp-core/src/limits/expiring-window.ts', edge: 'supabase/functions/_shared/limits/expiring-window.ts', driftChecked: true },
76
76
  { core: 'packages/mcp-core/src/rest/cors-origins.ts', edge: 'supabase/functions/_shared/api/cors-origins.ts', driftChecked: true },
77
77
  { core: 'packages/mcp-core/src/scope/scope-type-attribute.ts', edge: 'supabase/functions/_shared/scope/scope-type-attribute.ts', driftChecked: true },
78
+ // Which row an UNSCOPED read resolves to. Import-free on both sides (it only
79
+ // parses the scope prefix and compares strings), so the byte comparison
80
+ // applies — which is the point: a precedence that drifted between MCP and
81
+ // REST would hand the same key-only read two different lessons.
82
+ { core: 'packages/mcp-core/src/scope/scope-precedence.ts', edge: 'supabase/functions/_shared/scope/scope-precedence.ts', driftChecked: true },
78
83
  { core: 'packages/mcp-core/src/auth/account-wide-tools.ts', edge: 'supabase/functions/_shared/auth/account-wide-tools.ts', driftChecked: true },
79
84
  { core: 'packages/mcp-core/src/telemetry/io-ledger.ts', edge: 'supabase/functions/_shared/telemetry/io-ledger.ts', driftChecked: true },
80
85
  { core: 'packages/mcp-core/src/telemetry/db-query-metrics.ts', edge: 'supabase/functions/_shared/telemetry/db-query-metrics.ts', driftChecked: true },
@@ -91,6 +96,10 @@ export const mirrorPairs = [
91
96
  // `lessons-view.mjs` clustering, which is what `lorekit dedupe` runs — guarded
92
97
  // behaviourally by `duplicate-clusters-parity.spec.ts` instead.
93
98
  { core: 'packages/mcp-core/src/clusters/duplicate-clusters.ts', edge: 'supabase/functions/_shared/clusters/duplicate-clusters.ts', driftChecked: true },
99
+ // Batch `memory.read` (`refs`) core — grouping-by-scope, the key charset
100
+ // guard, and the found/missing set difference shared by MCP's `toolRead`
101
+ // and REST's `handleRead`. Import-free, so the byte comparison applies.
102
+ { core: 'packages/mcp-core/src/memory/read-refs.ts', edge: 'supabase/functions/_shared/memory/read-refs.ts', driftChecked: true },
94
103
  // Excluded from the byte-comparison drift check: the edge copy types the
95
104
  // client as `ReturnType<typeof createClient>` off an `npm:` specifier where
96
105
  // mcp-core imports a typed `SupabaseClient`, and additionally carries
@@ -0,0 +1,82 @@
1
+ // Which entry wins when a read named NO scope — the CLI's twin of
2
+ // `packages/mcp-core/src/scope/scope-precedence.ts` (and its edge mirror,
3
+ // `supabase/functions/_shared/scope/scope-precedence.ts`).
4
+ //
5
+ // Kept as a separate zero-dep module rather than imported: this package has no
6
+ // dependency on `@lorekit/core`, and the pair is a CROSS-LANGUAGE one (this
7
+ // `.mjs` vs that `.ts`), so it is guarded for BEHAVIOURAL parity by
8
+ // `scope-precedence-parity.spec.ts` rather than the byte comparison
9
+ // `mirror-pairs.mjs` runs over the two TypeScript copies — the same
10
+ // arrangement `session-kind-parity.spec.ts` uses.
11
+ //
12
+ // Distinct from `resolvePrecedence` in `lessons-pure.mjs`, which resolves a
13
+ // key across the CALLER'S OWN scope list (`deriveScope().readOrder`,
14
+ // narrow-to-broad). That one answers "the agent is standing in this repo, on
15
+ // this branch — whose lesson wins?". This one answers the question that only
16
+ // arises once NO scope was named at all, where the caller's own scopes are not
17
+ // the candidate set: every scope the account can see is. The server has no
18
+ // working directory to derive `readOrder` from, so this is the only definition
19
+ // MCP, REST and the CLI can all compute — which is why `show <key>` uses it
20
+ // too, instead of quietly resolving differently from the hosted surfaces.
21
+
22
+ /**
23
+ * Scope types in resolution order, most-specific first. The SAME order as
24
+ * `readOrder` — see the TS twin for why it is a list rather than a segment
25
+ * count.
26
+ */
27
+ export const SCOPE_PRECEDENCE = ['project', 'branch', 'repo', 'global'];
28
+
29
+ /**
30
+ * Rank a scope by type: 0 for the most specific, higher for broader. An
31
+ * unrecognised scope ranks LAST rather than throwing.
32
+ */
33
+ export function scopePrecedenceRank(scope) {
34
+ const raw = typeof scope === 'string' ? scope.trim().toLowerCase() : '';
35
+ const sep = raw.indexOf('::');
36
+ const prefix = sep === -1 ? raw : raw.slice(0, sep);
37
+ const at = SCOPE_PRECEDENCE.indexOf(prefix);
38
+ return at === -1 ? SCOPE_PRECEDENCE.length : at;
39
+ }
40
+
41
+ /**
42
+ * Order two candidates: precedence band, then most-recently-updated, then
43
+ * scope ascending. Total and deterministic — see the TS twin for why the last
44
+ * two tie-breaks are load-bearing rather than decoration.
45
+ */
46
+ export function compareScopePrecedence(a, b) {
47
+ const byRank = scopePrecedenceRank(a?.scope) - scopePrecedenceRank(b?.scope);
48
+ if (byRank !== 0) return byRank;
49
+ const at = typeof a?.updated_at === 'string' ? a.updated_at : '';
50
+ const bt = typeof b?.updated_at === 'string' ? b.updated_at : '';
51
+ if (at !== bt) return at < bt ? 1 : -1;
52
+ const as = typeof a?.scope === 'string' ? a.scope : '';
53
+ const bs = typeof b?.scope === 'string' ? b.scope : '';
54
+ return as < bs ? -1 : as > bs ? 1 : 0;
55
+ }
56
+
57
+ /** The entry an unscoped read resolves to, or `null` when there were none. */
58
+ export function pickScopeWinner(rows) {
59
+ if (!Array.isArray(rows) || rows.length === 0) return null;
60
+ let winner = rows[0];
61
+ for (let i = 1; i < rows.length; i += 1) {
62
+ if (compareScopePrecedence(rows[i], winner) < 0) winner = rows[i];
63
+ }
64
+ return winner;
65
+ }
66
+
67
+ /**
68
+ * Every scope the candidates cover EXCEPT the winner's, in precedence order,
69
+ * de-duplicated — what an unscoped read reports so the caller can see its key
70
+ * was ambiguous and pass an explicit scope next time.
71
+ */
72
+ export function shadowedScopes(rows, winner) {
73
+ if (!Array.isArray(rows) || !winner) return [];
74
+ const seen = new Set([winner.scope]);
75
+ const out = [];
76
+ for (const row of [...rows].sort(compareScopePrecedence)) {
77
+ if (seen.has(row.scope)) continue;
78
+ seen.add(row.scope);
79
+ out.push(row.scope);
80
+ }
81
+ return out;
82
+ }
@@ -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
  }