bc-observatory 0.5.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/server.js +450 -70
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -21,7 +21,7 @@ same order: references (`t36`, `cu 80`, `table 11300 BE`) and bare ids (`36`) go
21
21
  the title and an AL object's caption weigh 3, tags 2, summary 1, through the exact word, a prefix, the plural, a synonym
22
22
  (`client` is Customer, `G/L` is general ledger) or one typo; a name equal to the query adds 10; base app before
23
23
  first-party app before country layer, then what references, calls and documents an object; topic hubs by their size and
24
- review state, so "subscription" returns the Subscription billing hub before the codeunits that share the word. Fields,
24
+ review state, so "subscription" returns the Subscription billing hub before the codeunits that share the word. A field, event or procedure ranks where its owner ranks (base application before first-party app before country layer, then importance), and its tooltip adds nothing when its name equals the query; a `kind:` query that finds nothing says `No event named "..."`, names the nearest symbols and is never padded with pages by meaning. A question (`how to ...`, `difference between A and B`, `A vs B`) drops its question words when a content word is left, a quoted phrase keeps all its words, and the hint names the hub of the best Learn article. Fields,
25
25
  events, global procedures and enum values are searchable too (`OnAfterPostSalesDoc`, `field: Posting Date`); their path
26
26
  ends in an anchor (`objects/codeunit/80#event-OnAfterPostSalesDoc`). Filters: `type`, `tier`, `system`, `country`,
27
27
  `app`, `object_type`, `kind`. Each result says where it sits (`in:` the Learn TOC path, the app, the channel) and, for a
@@ -35,7 +35,7 @@ plain JavaScript; the 30 MB model is downloaded once on the first search, pinned
35
35
  `BC_OBSERVATORY_MODEL_DIR=<dir with config.json, tokenizer.json, model.safetensors>` uses a model on disk (offline). Without a
36
36
  model, search is keyword-only and says so.
37
37
 
38
- `get_object` takes an id, a reference (`t36`, `36-be`), a name or a caption, and a `country`; `whats_new` lists nothing
38
+ `get_object` takes an id, a reference (`t36`, `36-be`), a name or a caption, and a `country`; when that country's layer replaces a W1 object (`get_object("table", "18", "BE")`) the fields, events and procedures the layer adds print before the W1 page, and a lookup without a country ends with a `Replaced in:` line naming the layers; `search("field: Enterprise No.", country: "BE")` finds a member a layer adds. `whats_new` lists nothing
39
39
  dated after today unless `include_future: true`.
40
40
 
41
41
  `cat`, `get_object` and `localization` return a page as it is when it fits 30,000 characters. A longer page
package/dist/server.js CHANGED
@@ -37,6 +37,7 @@ var KIND_OF = { fields: "field", events: "event", procs: "proc", values: "value"
37
37
  var fold = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
38
38
  var TOKEN = /[\p{L}\p{N}]+(?:[.\-/][\p{L}\p{N}]+)*/gu;
39
39
  var keep = (t) => t.length > 1 || /\d/.test(t);
40
+ var isCodedId = (w) => /^[A-Z]{2,3}\d{4}$/.test(w);
40
41
  function tokenize(s) {
41
42
  const out = [];
42
43
  for (const m of fold(s).matchAll(TOKEN)) if (keep(m[0])) out.push(m[0]);
@@ -50,14 +51,17 @@ function camelParts(raw) {
50
51
  if (!/[a-z][A-Z]|[A-Z]{2}[a-z]/.test(raw)) return [];
51
52
  return (raw.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []).map((w) => fold(w)).filter(keep);
52
53
  }
53
- function indexTokens(raw, camel = false) {
54
- const out = /* @__PURE__ */ new Set();
54
+ function seqTokens(raw, camel = false) {
55
+ const out = [];
55
56
  for (const t of tokenize(raw)) {
56
- out.add(t);
57
- for (const p of parts(t)) out.add(p);
57
+ out.push(t);
58
+ out.push(...parts(t));
58
59
  }
59
- if (camel) for (const w of raw.split(/[^\p{L}\p{N}]+/u)) for (const p of camelParts(w)) out.add(p);
60
- return [...out];
60
+ if (camel) for (const w of raw.split(/[^\p{L}\p{N}]+/u)) out.push(...camelParts(w));
61
+ return out;
62
+ }
63
+ function indexTokens(raw, camel = false) {
64
+ return [...new Set(seqTokens(raw, camel))];
61
65
  }
62
66
  function stem(t) {
63
67
  if (t.length < 5 || /\d/.test(t) || /[.\-/]/.test(t)) return t;
@@ -181,7 +185,53 @@ var SYSTEM_ALIASES = {
181
185
  development: ["al", "extension", "codeunit", "table", "page", "event", "interface", "namespace", "al language", "vs code", "analyzer", "compiler", "testability", "permission set"],
182
186
  platform: ["al-go", "pipeline", "docker", "container", "telemetry", "application insights", "performance", "database", "sql", "upgrade", "update", "release", "wave", "deprecation", "obsolete", "web client", "user experience"]
183
187
  };
184
- var QUESTION_WORDS = /* @__PURE__ */ new Set(["how", "what", "why", "where", "when", "which", "who", "do", "does", "did", "can", "could", "should", "is", "are", "was", "were", "the", "an", "to", "in", "of", "for", "on", "my", "me", "it", "from", "with", "and", "or", "changed", "change"]);
188
+ var QUESTION_WORDS = /* @__PURE__ */ new Set([
189
+ "how",
190
+ "what",
191
+ "why",
192
+ "where",
193
+ "when",
194
+ "which",
195
+ "who",
196
+ "do",
197
+ "does",
198
+ "did",
199
+ "can",
200
+ "could",
201
+ "should",
202
+ "is",
203
+ "are",
204
+ "was",
205
+ "were",
206
+ "the",
207
+ "an",
208
+ "to",
209
+ "in",
210
+ "of",
211
+ "for",
212
+ "on",
213
+ "my",
214
+ "me",
215
+ "it",
216
+ "from",
217
+ "with",
218
+ "and",
219
+ "or",
220
+ "changed",
221
+ "change",
222
+ "difference",
223
+ "differences",
224
+ "between",
225
+ "vs",
226
+ "versus",
227
+ "compare",
228
+ "comparison",
229
+ "use",
230
+ "used",
231
+ "using",
232
+ "mean",
233
+ "means"
234
+ ]);
185
235
  var norm = (s) => s.toLowerCase().replace(/\.(?=\s|$)/g, "").trim();
186
236
  var table = null;
187
237
  function synonymsOf(word) {
@@ -223,11 +273,22 @@ function term(text2, phrase = false, raw = text2) {
223
273
  const alts = [...synonymsOf(text2)];
224
274
  const camel = camelParts(raw);
225
275
  if (camel.length > 1) alts.push(camel.join(" "));
226
- return { text: text2, stem: phrase ? text2.split(" ").map(stem).join(" ") : stem(text2), alts, phrase, numeric: /^\d+$/.test(text2) };
227
- }
276
+ return { text: text2, stem: phrase ? text2.split(" ").map(stem).join(" ") : stem(text2), alts, phrase, numeric: /^\d+$/.test(text2), raw: raw || text2 };
277
+ }
278
+ var WH_START = /^(how|what|why|where|when|which|who)\b/i;
279
+ var AUX_PRONOUN = /^(is|are|can|could|should|do|does)\s+(i|you|we|it|there|this|that|my|they|he|she)\b/i;
280
+ var AUX_ARTICLE = /^(is|are|can|could|should|do|does)\s+(a|an|the)\b/i;
281
+ var COMPARE_START = /^compare\s+\S+(?:\s+\S+)*?\s+(?:and|with|to|vs|versus)\s+\S/i;
282
+ var VERSUS = /[\p{L}\p{N}]\S*\s+(?:vs|versus)(?![.\p{L}\p{N}])\s+(?!code\b)[\p{L}\p{N}]/iu;
283
+ var USE_AFTER = /* @__PURE__ */ new Set(["to", "i", "we", "you", "it", "can", "should", "between", "and", "or"]);
284
+ var isQuestion = (s) => {
285
+ if (WH_START.test(s)) return true;
286
+ if (!/^IS\b/.test(s) && (AUX_PRONOUN.test(s) || AUX_ARTICLE.test(s) && (s.split(/\s+/).length >= 5 || s.endsWith("?")))) return true;
287
+ return COMPARE_START.test(s) || /\bchanged in\b/i.test(s) || /\bdifferences? between\b/i.test(s) || VERSUS.test(s);
288
+ };
228
289
  function parseQuery(raw, opts) {
229
290
  const countries = new Set(opts.countries.map((c) => c.toLowerCase()));
230
- const q = { raw, ref: null, number: null, types: [], kind: null, major: null, countries: [], question: false, terms: [], warnings: [] };
291
+ const q = { raw, ref: null, number: null, types: [], kind: null, major: null, countries: [], question: false, dropped: [], shape: null, terms: [], warnings: [] };
231
292
  let s = raw.trim();
232
293
  const phrases = [];
233
294
  s = s.replace(/"([^"]*)"/g, (_, p) => {
@@ -242,7 +303,7 @@ function parseQuery(raw, opts) {
242
303
  }
243
304
  if (!q.kind && !phrases.length) {
244
305
  const m = /^([a-z]+)\s*(\d+)(?:(?:\s+|\s*-\s*)([a-z]{2}))?$/i.exec(s);
245
- if (m) {
306
+ if (m && !(isCodedId(s.toUpperCase()) && !typeOfWord(m[1]))) {
246
307
  const type = typeOfWord(m[1]);
247
308
  const cc = m[3]?.toLowerCase() ?? null;
248
309
  if (type && (!cc || countries.has(cc))) {
@@ -262,7 +323,8 @@ function parseQuery(raw, opts) {
262
323
  q.major = v;
263
324
  return " ";
264
325
  }).trim();
265
- q.question = /^(how|what|why|where|when|which)\b/i.test(s) || /\bchanged in\b/i.test(s);
326
+ q.question = !q.kind && isQuestion(s);
327
+ if (q.question) q.shape = /\bchanged in\b/i.test(s) ? "what-changed" : /^(how (do|to)|when to)\b/i.test(s) ? "how-to" : "concept";
266
328
  const words = s.split(/\s+/).filter(Boolean);
267
329
  const typeAt = (i) => i >= 0 && i < words.length && !!TYPE_WORDS[words[i].toLowerCase()];
268
330
  const typeWords = [];
@@ -291,12 +353,24 @@ function parseQuery(raw, opts) {
291
353
  const t = tokenize(w);
292
354
  for (const x of t) toks.push({ text: x, raw: t.length === 1 ? w.replace(/[^\p{L}\p{N}.\-/]/gu, "") : x });
293
355
  }
294
- if (q.question) toks = toks.filter((t) => !QUESTION_WORDS.has(t.text) && !(t.text.length === 1));
356
+ if (q.question) {
357
+ const dropWord = (i) => {
358
+ const w = toks[i].text;
359
+ if (w === "vs" || w === "versus") return i > 0 && i < toks.length - 1 && !toks[i].raw.endsWith(".") && toks[i + 1].text !== "code";
360
+ if (w === "use" || w === "used" || w === "using") return USE_AFTER.has(toks[i - 1]?.text);
361
+ return QUESTION_WORDS.has(w);
362
+ };
363
+ const gone = new Set(toks.map((_, i) => i).filter((i) => dropWord(i) || toks[i].text.length === 1));
364
+ if (phrases.length || toks.some((_, i) => !gone.has(i))) {
365
+ for (const i of gone) if (QUESTION_WORDS.has(toks[i].text) && !q.dropped.includes(toks[i].text)) q.dropped.push(toks[i].text);
366
+ toks = toks.filter((_, i) => !gone.has(i));
367
+ }
368
+ }
295
369
  const multi = synonymPhrases().map((p) => p.split(" ")).sort((a, b) => b.length - a.length);
296
370
  for (let i = 0; i < toks.length; i++) {
297
371
  const hit = multi.find((p) => p.every((w, j) => toks[i + j]?.text === w));
298
372
  if (hit) {
299
- q.terms.push(term(hit.join(" "), true));
373
+ q.terms.push({ ...term(hit.join(" "), true), raw: toks.slice(i, i + hit.length).map((t) => t.raw || t.text).join(" ") });
300
374
  i += hit.length - 1;
301
375
  continue;
302
376
  }
@@ -349,6 +423,8 @@ function pageToRecord(p) {
349
423
  function symbolToRecord(kind, row, owner) {
350
424
  const name = String(row[1]);
351
425
  const key = kind === "field" || kind === "value" ? Number(row[2]) : name;
426
+ const last = row[kind === "field" ? 5 : kind === "value" ? 4 : 6];
427
+ const cc = typeof last === "string" && /^[a-z]{2}$/i.test(last) ? last.toLowerCase() : null;
352
428
  const base = {
353
429
  id: `${owner.id}${anchorOf(kind, key)}`,
354
430
  kind,
@@ -357,9 +433,9 @@ function symbolToRecord(kind, row, owner) {
357
433
  title: name,
358
434
  objectType: owner.objectType,
359
435
  objectId: owner.objectId ?? null,
360
- country: owner.country ?? null,
361
- app: owner.app ?? null,
362
- layer: owner.layer,
436
+ country: cc ?? owner.country ?? null,
437
+ app: cc ? `${cc.toUpperCase()} layer` : owner.app ?? null,
438
+ layer: cc ? 2 : owner.layer,
363
439
  importance: owner.importance,
364
440
  inbound: owner.inbound ?? 0,
365
441
  system: owner.system ?? null,
@@ -395,12 +471,20 @@ function normOf(r) {
395
471
  const own = r.type === "topic" ? new Set(tokenize(r.title)) : null;
396
472
  const learn = r.type === "learn";
397
473
  const tags = (r.tags ?? []).flatMap((t) => indexTokens(t, learn)).filter((t) => !own?.has(t));
398
- const eqs = r.type === "object" || symbol ? [tokenize(r.name), ...r.caption ? [tokenize(r.caption)] : []] : [tokenize(r.title)];
474
+ const eqs = r.type === "object" || symbol ? [tokenize(r.name), ...r.caption ? [tokenize(r.caption)] : []] : [tokenize(r.title), ...learn ? (r.tags ?? []).filter(isCodedId).map((t) => tokenize(t)).filter((t) => t.length === 1) : []];
399
475
  const n = { main: [...main], tags: learn ? tags : [...new Set(tags)], text: symbol ? null : indexTokens(r.text ?? ""), eqs };
400
476
  NORM.set(r, n);
401
477
  return n;
402
478
  }
403
479
  var textOf = (r, n) => n.text ??= indexTokens(r.text ?? "");
480
+ function seqs(r) {
481
+ let main, text2;
482
+ const symbol = r.kind !== "page";
483
+ return {
484
+ main: () => main ??= [seqTokens(r.name, symbol), seqTokens(r.title, symbol), ...r.caption ? [seqTokens(r.caption)] : []].flatMap((f, k) => k ? ["", ...f] : f),
485
+ text: () => text2 ??= seqTokens(r.text ?? "")
486
+ };
487
+ }
404
488
  function prepare(records, prev) {
405
489
  const index2 = prev ?? { records: [], postings: /* @__PURE__ */ new Map(), vocab: /* @__PURE__ */ new Map(), byType: /* @__PURE__ */ new Map(), maxInbound: /* @__PURE__ */ new Map() };
406
490
  let st = STATE.get(index2);
@@ -522,7 +606,7 @@ function matchersOf(index2, q) {
522
606
  }
523
607
  return m;
524
608
  }
525
- function best(m, toks) {
609
+ function best(m, toks, seq = () => toks) {
526
610
  let b = null;
527
611
  for (const t of toks) {
528
612
  const f = m.words.get(t);
@@ -530,7 +614,8 @@ function best(m, toks) {
530
614
  }
531
615
  for (const p of m.phrases) {
532
616
  if (b && b.f >= p.f) continue;
533
- const f = phraseForm(p.words, toks);
617
+ if (!p.words.every((w) => holds(toks, w))) continue;
618
+ const f = phraseForm(p.words, seq());
534
619
  if (f) {
535
620
  const v = p.f * f;
536
621
  if (!b || v > b.f) b = { f: v, how: p.how, ...p.via ? { via: p.via } : {} };
@@ -538,6 +623,21 @@ function best(m, toks) {
538
623
  }
539
624
  return b;
540
625
  }
626
+ var STEMS = /* @__PURE__ */ new Map();
627
+ var stemOf = (t) => {
628
+ let s = STEMS.get(t);
629
+ if (s === void 0) {
630
+ s = stem(t);
631
+ STEMS.set(t, s);
632
+ }
633
+ return s;
634
+ };
635
+ function holds(toks, w) {
636
+ if (toks.includes(w)) return true;
637
+ const sw = stemOf(w);
638
+ for (const t of toks) if (t[0] === w[0] && stemOf(t) === sw) return true;
639
+ return false;
640
+ }
541
641
  function phraseForm(words, toks) {
542
642
  let bestF = 0;
543
643
  for (let i = 0; i + words.length <= toks.length; i++) {
@@ -551,7 +651,7 @@ function phraseForm(words, toks) {
551
651
  }
552
652
  return bestF;
553
653
  }
554
- function nameMatch(eq, ms, plural) {
654
+ function nameMatch(eq, ms, plural2) {
555
655
  let pos = 0, f = 1, lastPrefix = false;
556
656
  for (const m of ms) {
557
657
  const ws = m.term.phrase ? m.term.text.split(" ") : null;
@@ -559,7 +659,7 @@ function nameMatch(eq, ms, plural) {
559
659
  for (const w of ws) {
560
660
  const t2 = eq[pos++];
561
661
  if (t2 === void 0) return { eq: 0, starts: 0 };
562
- f = Math.min(f, t2 === w ? 1 : stem(t2) === stem(w) ? plural ? 1 : 0.75 : 0);
662
+ f = Math.min(f, t2 === w ? 1 : stem(t2) === stem(w) ? plural2 ? 1 : 0.75 : 0);
563
663
  }
564
664
  if (!f) return { eq: 0, starts: 0 };
565
665
  lastPrefix = false;
@@ -576,10 +676,10 @@ function nameMatch(eq, ms, plural) {
576
676
  lastPrefix = p.how === "prefix";
577
677
  continue;
578
678
  }
579
- f = Math.min(f, form.how === "stem" && plural ? 1 : form.f);
679
+ f = Math.min(f, form.how === "stem" && plural2 ? 1 : form.f);
580
680
  lastPrefix = form.how === "prefix";
581
681
  }
582
- if (pos === eq.length && !lastPrefix && (plural || f === 1)) return { eq: f, starts: 0 };
682
+ if (pos === eq.length && !lastPrefix && (plural2 || f === 1)) return { eq: f, starts: 0 };
583
683
  return { eq: 0, starts: pos <= eq.length ? f : 0 };
584
684
  }
585
685
  var NARRATIVE = { reviewed: 2, unreviewed: 0, none: -2, derived: -2 };
@@ -600,7 +700,7 @@ function scoreRecord(index2, r, q) {
600
700
  if (q.ref.country) return { s: country === q.ref.country ? 1e3 : country === null ? 900 : 850, why };
601
701
  return { s: country === null ? 1e3 : 900, why };
602
702
  }
603
- const prior = object ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
703
+ const prior = objectish ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
604
704
  const lift = (base) => base + (object ? LAYER[r.layer] : symbol ? 0 : 1) + (objectish ? 2 * r.importance : 0) + prior;
605
705
  if (q.number !== null) {
606
706
  if (!object || r.objectId !== q.number) return none;
@@ -616,10 +716,10 @@ function scoreRecord(index2, r, q) {
616
716
  if (object && country && q.countries.includes(country)) return { s: 3 + 2 * r.importance + prior, why };
617
717
  return none;
618
718
  }
619
- const n = normOf(r), ms = matchersOf(index2, q);
620
- let s = 0, named = false, gate = !symbol || !!q.kind;
719
+ const n = normOf(r), ms = matchersOf(index2, q), sq = seqs(r);
720
+ let s = 0, textPts = 0, named = false, gate = !symbol || !!q.kind;
621
721
  for (const m of ms) {
622
- const main = best(m, n.main), tags = best(m, n.tags), text2 = best(m, textOf(r, n));
722
+ const main = best(m, n.main, sq.main), tags = best(m, n.tags), text2 = best(m, textOf(r, n), sq.text);
623
723
  if (!main && !tags && !text2) return none;
624
724
  if (main || tags) named = true;
625
725
  if (symbol && !gate && n.eqs[0].some((t) => {
@@ -628,21 +728,25 @@ function scoreRecord(index2, r, q) {
628
728
  })) gate = true;
629
729
  if (symbol && /^on[a-z]/.test(m.term.text)) gate = true;
630
730
  s += 3 * (main?.f ?? 0) + 2 * (tags?.f ?? 0) + (text2?.f ?? 0);
731
+ textPts += text2?.f ?? 0;
631
732
  const via = [main, tags, text2].find((x) => x?.via && (x.how === "syn" || x.how === "fuzzy"))?.via;
632
733
  if (via && !why.includes(`matched "${via}"`)) why.push(`matched "${via}"`);
633
734
  }
634
735
  if (!gate) return none;
736
+ const learn = r.type === "learn";
635
737
  let eq = 0, starts = 0, byName = false;
636
738
  n.eqs.forEach((e, k) => {
637
739
  const x = nameMatch(e, ms, objectish);
638
740
  if (x.eq > eq) {
639
741
  eq = x.eq;
640
- byName = k === 0;
742
+ byName = k === 0 || learn;
641
743
  }
642
- starts = Math.max(starts, x.starts);
744
+ if (!learn || k === 0) starts = Math.max(starts, x.starts);
643
745
  });
746
+ const lifted = q.kind || q.types.length || /\d/.test(q.raw) || OBJECT_WORDS.test(q.raw) || object && eq && byName || symbol && ms.some((m) => /^on[a-z]/.test(m.term.text) && m.term.alts.some((a) => a.startsWith("on ")));
747
+ const owned = symbol && !!lifted;
644
748
  if (eq) {
645
- s += 10 * eq;
749
+ s += 10 * eq - (owned ? textPts : 0);
646
750
  why.push(!byName ? "caption" : eq >= 0.75 ? "name" : "name~");
647
751
  } else if (starts) {
648
752
  s += 3 * starts;
@@ -652,8 +756,7 @@ function scoreRecord(index2, r, q) {
652
756
  if ((r.type === "topic" || r.type === "app") && !symbol) s += NARRATIVE[r.narrative ?? (r.type === "app" ? "none" : "unreviewed")] ?? 0;
653
757
  s += countryPoints(q, r, country);
654
758
  if (q.types.length && objectish) s += 3;
655
- s = lift(s);
656
- const lifted = q.kind || q.types.length || /\d/.test(q.raw) || OBJECT_WORDS.test(q.raw) || object && eq && byName || symbol && ms.some((m) => /^on[a-z]/.test(m.term.text) && m.term.alts.some((a) => a.startsWith("on ")));
759
+ s = symbol ? s + 2 * r.importance + (owned ? LAYER[r.layer] + prior : 0) : lift(s);
657
760
  if (objectish && !lifted) {
658
761
  s *= 0.6;
659
762
  why.push("demoted");
@@ -695,11 +798,11 @@ function search(index2, q, opts = {}) {
695
798
  return opts.limit ? hits.slice(0, opts.limit) : hits;
696
799
  }
697
800
  function symbolLine(r) {
698
- const of = r.owner ? ` of ${r.owner.title}` : "", x = r.extra ?? {};
699
- if (r.kind === "field") return `field ${x.id}${of} \xB7 ${x.type}`;
700
- if (r.kind === "event") return `event${of} \xB7 ${x.kind} \xB7 ${x.subscribers} ${x.subscribers === 1 ? "subscriber" : "subscribers"}${x.obsolete ? ` \xB7 obsolete ${x.obsolete}` : ""}`;
701
- if (r.kind === "proc") return `procedure${of} \xB7 ${x.params} ${x.params === 1 ? "parameter" : "parameters"}${x.returns ? ` \xB7 returns ${x.returns}` : ""}${x.obsolete ? ` \xB7 obsolete ${x.obsolete}` : ""}`;
702
- if (r.kind === "value") return `value ${x.ordinal}${of}${x.caption && x.caption !== r.name ? ` \xB7 "${x.caption}"` : ""}`;
801
+ const of2 = `${r.owner ? ` of ${r.owner.title}` : ""}${r.country && r.app === `${r.country.toUpperCase()} layer` ? ` \xB7 ${r.app}` : ""}`, x = r.extra ?? {};
802
+ if (r.kind === "field") return `field ${x.id}${of2} \xB7 ${x.type}`;
803
+ if (r.kind === "event") return `event${of2} \xB7 ${x.kind} \xB7 ${x.subscribers} ${x.subscribers === 1 ? "subscriber" : "subscribers"}${x.obsolete ? ` \xB7 obsolete ${x.obsolete}` : ""}`;
804
+ if (r.kind === "proc") return `procedure${of2} \xB7 ${x.params} ${x.params === 1 ? "parameter" : "parameters"}${x.returns ? ` \xB7 returns ${x.returns}` : ""}${x.obsolete ? ` \xB7 obsolete ${x.obsolete}` : ""}`;
805
+ if (r.kind === "value") return `value ${x.ordinal}${of2}${x.caption && x.caption !== r.name ? ` \xB7 "${x.caption}"` : ""}`;
703
806
  return r.title;
704
807
  }
705
808
  function objectsAt(index2, type, id) {
@@ -720,7 +823,8 @@ function typoWords(index2, q) {
720
823
  function namedBy(index2, r, q) {
721
824
  if (!STATE.get(index2)) return 0;
722
825
  const n = normOf(r);
723
- return matchersOf(index2, q).reduce((a, m) => a + (best(m, n.main)?.f ?? best(m, n.tags)?.f ?? 0), 0);
826
+ const sq = seqs(r);
827
+ return matchersOf(index2, q).reduce((a, m) => a + (best(m, n.main, sq.main)?.f ?? best(m, n.tags)?.f ?? 0), 0);
724
828
  }
725
829
  function symbolRecords(kind, rows, ownerOf) {
726
830
  const out = [];
@@ -736,7 +840,7 @@ function countriesOf(index2) {
736
840
  }
737
841
 
738
842
  // ../search/src/hints.ts
739
- function hints(index2, q, hits) {
843
+ function hints(index2, q, hits, opts = {}) {
740
844
  const out = [];
741
845
  const unknown = q.warnings.map((w) => /^unknown abbreviation (\w+)$/.exec(w)?.[1]).find(Boolean);
742
846
  const digits = /(\d+)\s*$/.exec(q.raw)?.[1];
@@ -768,21 +872,89 @@ function hints(index2, q, hits) {
768
872
  out.push({ text: `${t.term}: no page; ${words.map((x) => `${x.w} (${x.n.toLocaleString("en")} ${x.n === 1 ? "page" : "pages"})`).join(", ")}`, query: swap(words[0].w) });
769
873
  }
770
874
  if (q.question) {
771
- let hub = null, best2 = 0;
772
- for (const i of index2.byType.get("topic") ?? []) {
773
- const r = index2.records[i], named = namedBy(index2, r, q);
774
- if (!named) continue;
775
- const s = 3 * named + Math.log2((r.members ?? 0) + 1) + (r.narrative === "reviewed" ? 2 : r.narrative === "none" ? -2 : 0);
776
- if (s > best2) {
777
- best2 = s;
778
- hub = r;
875
+ const hubOf = () => {
876
+ let hub = null, best2 = 0;
877
+ for (const i of index2.byType.get("topic") ?? []) {
878
+ const r = index2.records[i], named = namedBy(index2, r, q);
879
+ if (!named) continue;
880
+ const s = 3 * named + Math.log2((r.members ?? 0) + 1) + (r.narrative === "reviewed" ? 2 : r.narrative === "none" ? -2 : 0);
881
+ if (s > best2) {
882
+ best2 = s;
883
+ hub = r;
884
+ }
779
885
  }
886
+ return hub;
887
+ };
888
+ if (q.shape === "what-changed" || !q.shape) {
889
+ const hub = hubOf();
890
+ out.push({ text: `For what changed, open the object's Versions section or Changes; for how-to, start with the hub${hub ? `: ${hub.title}` : ""}.`, ...hub ? { path: hub.id } : {} });
891
+ } else {
892
+ const hub = learnHub(index2, hits) ?? hubOf();
893
+ if (hub) out.push({ text: `For ${q.shape === "how-to" ? "how-to" : "a concept"}, start with the hub: ${hub.title}.`, path: hub.id });
894
+ }
895
+ }
896
+ const kind = q.kind ?? opts.kind ?? null;
897
+ if (kind && !hits.length) {
898
+ const name = nameOf2(q);
899
+ if (opts.unfiltered?.length) {
900
+ const n = opts.unfiltered.length;
901
+ out.unshift({ text: `No ${KIND_WORD[kind]} named "${name}" matches the filters${opts.filters ? ` (${opts.filters})` : ""}; without them: ${n} ${n === 1 ? "hit" : "hits"}.` });
902
+ } else {
903
+ const h = nearestSymbols(index2, kind, name, opts.keep);
904
+ if (h) out.unshift(h);
780
905
  }
781
- out.push({ text: `For what changed, open the object's Versions section or Changes; for how-to, start with the hub${hub ? `: ${hub.title}` : ""}.`, ...hub ? { path: hub.id } : {} });
782
906
  }
783
- void hits;
784
907
  return out;
785
908
  }
909
+ function learnHub(index2, hits) {
910
+ for (const h of hits.slice(0, 10)) {
911
+ const path = h.r.type === "learn" ? h.r.page?.hub : void 0;
912
+ if (!path) continue;
913
+ const hub = (index2.byType.get("topic") ?? []).map((i) => index2.records[i]).find((r) => r.id === path);
914
+ if (hub) return hub;
915
+ }
916
+ return null;
917
+ }
918
+ var KIND_WORD = { field: "field", event: "event", proc: "procedure", value: "value" };
919
+ function nameOf2(q) {
920
+ const typed = q.terms.map((t) => t.raw || t.text).join(" ").replace(/"/g, "").trim();
921
+ return typed || (q.kind ? q.raw.replace(/^\s*\w+\s*:\s*/, "").replace(/"/g, "").trim() : "");
922
+ }
923
+ function nearestSymbols(index2, kind, name, keep2) {
924
+ const all = index2.byType.get(kind) ?? [];
925
+ const want = name.toLowerCase();
926
+ if (!all.length || !want) return null;
927
+ const word = KIND_WORD[kind], head1 = `No ${word} named "${name}".`;
928
+ const near = [];
929
+ if (want.length >= 4) {
930
+ const head = want.slice(0, 5), shared = Math.ceil(0.75 * want.length), reach = Math.min(2, Math.max(1, Math.floor(want.length / 4)));
931
+ for (const i of all) {
932
+ const r = index2.records[i], n = r.name.toLowerCase();
933
+ if (n === want) return null;
934
+ if (keep2 && !keep2(r)) continue;
935
+ let ok = editDistance(want, n, reach) <= reach;
936
+ if (!ok && n.startsWith(head)) {
937
+ let k = 0;
938
+ while (k < n.length && k < want.length && n[k] === want[k]) k++;
939
+ ok = k >= shared;
940
+ }
941
+ if (ok) near.push({ r, d: editDistance(want, n, 60) });
942
+ }
943
+ } else if (all.some((i) => index2.records[i].name.toLowerCase() === want)) return null;
944
+ near.sort((a, b) => a.d - b.d || b.r.importance - a.r.importance || a.r.name.localeCompare(b.r.name) || a.r.id.localeCompare(b.r.id));
945
+ const seen = /* @__PURE__ */ new Set(), pick = [];
946
+ for (const { r } of near) {
947
+ if (pick.length >= 3) break;
948
+ if (seen.has(r.id)) continue;
949
+ seen.add(r.id);
950
+ pick.push(r);
951
+ }
952
+ if (!pick.length) return { text: head1 };
953
+ const plural2 = word === "procedure" ? "procedures" : `${word}s`;
954
+ const label = (r) => r.owner ? `${r.name} (${r.owner.title})` : r.name;
955
+ const qk = kind === "proc" ? "proc" : kind;
956
+ return { text: `${head1} Nearest ${plural2}: ${pick.map(label).join(", ")}.`, query: `${qk}: ${pick[0].name}` };
957
+ }
786
958
 
787
959
  // ../search/src/learn.ts
788
960
  function learnRecords(doc) {
@@ -912,6 +1084,139 @@ function dot(a, b, bOffset = 0) {
912
1084
  return s;
913
1085
  }
914
1086
 
1087
+ // src/country.ts
1088
+ var BLOCK_MEMBERS = 40;
1089
+ var DELTA_CLIP = 140;
1090
+ var KINDS2 = ["fields", "values", "events", "procedures", "keys"];
1091
+ var NOUN = { fields: ["field", "fields"], values: ["value", "values"], events: ["event", "events"], procedures: ["procedure", "procedures"], keys: ["key", "keys"] };
1092
+ var plural = (n, kind) => `${n} ${NOUN[kind][n === 1 ? 0 : 1]}`;
1093
+ var of = (o, kind, change) => (o[kind] ?? []).filter((m) => m.change === change);
1094
+ var clip = (s, n = DELTA_CLIP) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
1095
+ function titleOfKey(key, name) {
1096
+ const [type, id] = key.split("/");
1097
+ return `${type.charAt(0).toUpperCase()}${type.slice(1)} ${id} "${name}"`;
1098
+ }
1099
+ function addedSummary(o) {
1100
+ return KINDS2.filter((k) => k !== "keys").map((k) => [k, of(o, k, "added").length]).filter(([, n]) => n > 0).map(([k, n]) => `+${plural(n, k)}`).join(", ");
1101
+ }
1102
+ function replacedInLine(layers) {
1103
+ if (!layers.length) return null;
1104
+ const parts2 = [...layers].sort((a, b) => a.cc.localeCompare(b.cc)).map(({ cc, object }) => {
1105
+ const s = addedSummary(object);
1106
+ return `${cc.toUpperCase()}${s ? ` (${s})` : ""}`;
1107
+ });
1108
+ return `Replaced in: ${parts2.join(", ")}.`;
1109
+ }
1110
+ var paramsOf = (ps) => (ps ?? []).map((p) => `${p.var ? "var " : ""}${p.name}: ${p.type}`).join("; ");
1111
+ var signature = (name, to) => `${name}(${paramsOf(to.params)})${to.returns ? `: ${to.returns}` : ""}`;
1112
+ var procLine = (m) => {
1113
+ const to = m.to ?? {};
1114
+ return `${signature(m.name, to)} (${to.event && to.event !== "subscriber" ? to.event : to.scope ?? "global"})${to.obsolete?.state ? ` [obsolete ${to.obsolete.state}]` : ""}`;
1115
+ };
1116
+ var fieldLine = (m) => `${m.id} "${m.name}" ${(m.to ?? {}).type ?? ""}`.trimEnd();
1117
+ function propertiesText(p) {
1118
+ return Object.entries(p ?? {}).map(([k, v]) => v === "true" ? k : /^(Caption|ToolTip)/.test(k) ? `${k} "${v}"` : `${k} ${v}`).join("; ");
1119
+ }
1120
+ function deltaText(d) {
1121
+ if (!d) return "";
1122
+ const show = (v) => typeof v === "string" ? v : JSON.stringify(v);
1123
+ const parts2 = [];
1124
+ for (const [k, v] of Object.entries(d)) {
1125
+ if (k === "properties") {
1126
+ for (const [pk, pv] of Object.entries(v)) parts2.push(`${pk}: ${pv[0] ?? "-"} -> ${pv[1] ?? "-"}`);
1127
+ continue;
1128
+ }
1129
+ const [a, b] = v;
1130
+ if (Array.isArray(a) && Array.isArray(b)) {
1131
+ const add = b.filter((x) => !a.includes(x)), rem = a.filter((x) => !b.includes(x));
1132
+ parts2.push(`${k} ${[...add.map((x) => `+${x}`), ...rem.map((x) => `-${x}`)].join(", ") || "reordered"}`);
1133
+ } else parts2.push(`${k}: ${show(a ?? "-")} -> ${show(b ?? "-")}`);
1134
+ }
1135
+ return clip(parts2.join("; "));
1136
+ }
1137
+ function keyLine(m) {
1138
+ if (m.change === "added") return `${m.name} (new: ${((m.to ?? {}).fields ?? []).join(", ")})`;
1139
+ if (m.change === "removed") return `${m.name} (removed)`;
1140
+ return `${m.name} (${deltaText(m.delta) || "changed"})`.replace(/\(fields /, "(");
1141
+ }
1142
+ function headline(o, cc, major, title) {
1143
+ const added = addedSummary(o);
1144
+ const other = [];
1145
+ for (const k of KINDS2) {
1146
+ const changed = of(o, k, "changed").length + (k === "keys" ? of(o, k, "added").length : 0), removed = of(o, k, "removed").length;
1147
+ if (changed) other.push(`${plural(changed, k)} changed`);
1148
+ if (removed) other.push(`${plural(removed, k)} removed`);
1149
+ }
1150
+ if (o.properties?.length) other.push(`${o.properties.length} ${o.properties.length === 1 ? "property" : "properties"} changed`);
1151
+ const tail = [added || "no member added", ...other.length ? [other.join(", ")] : []].join("; ");
1152
+ return `${cc.toUpperCase()} replaces ${title} (BC${major}): ${tail}.`;
1153
+ }
1154
+ function countryBlock(o, cc, major, opts = {}) {
1155
+ const C = cc.toUpperCase(), title = opts.title ?? titleOfKey(o.key, o.name);
1156
+ const groups = [
1157
+ ["Fields added", of(o, "fields", "added").map(fieldLine)],
1158
+ ["Values added", of(o, "values", "added").map((m) => `${m.id} ${m.name}`)],
1159
+ ["Events added", of(o, "events", "added").map(procLine)],
1160
+ ["Procedures added", of(o, "procedures", "added").map(procLine)],
1161
+ ["Keys changed", [...of(o, "keys", "changed"), ...of(o, "keys", "added"), ...of(o, "keys", "removed")].map(keyLine)],
1162
+ ["Fields changed", of(o, "fields", "changed").map((m) => `${m.id} "${m.name}"${m.delta ? ` (${deltaText(m.delta)})` : ""}`)],
1163
+ ["Events changed", of(o, "events", "changed").map((m) => `${m.name}${m.delta ? ` (${deltaText(m.delta)})` : ""}`)],
1164
+ ["Procedures changed", of(o, "procedures", "changed").map((m) => `${m.name}${m.delta ? ` (${deltaText(m.delta)})` : ""}`)]
1165
+ ];
1166
+ const out = [headline(o, cc, major, title)];
1167
+ let room = BLOCK_MEMBERS, more = 0;
1168
+ for (const [label, items] of groups) {
1169
+ if (!items.length) continue;
1170
+ const shown = items.slice(0, Math.max(0, room));
1171
+ room -= shown.length;
1172
+ more += items.length - shown.length;
1173
+ if (shown.length) out.push(`${label}: ${shown.join("; ")}`);
1174
+ }
1175
+ if (more) out.push(`... and ${more} more: localization("${C}", section: "w1_objects_this_country_changes")`);
1176
+ out.push(`Source: data/code/diffs/country/${major}-${cc.toLowerCase()}.json; the ${C} page: localizations/${cc.toLowerCase()}.`);
1177
+ return out.join("\n");
1178
+ }
1179
+ function parseAnchor(anchor) {
1180
+ const m = /^#?(field|value|event|proc)-(.+)$/.exec(anchor);
1181
+ return m ? { kind: m[1], key: m[2] } : null;
1182
+ }
1183
+ function memberAnswer(o, a, cc, opts) {
1184
+ const C = cc.toUpperCase(), title = opts.title ?? titleOfKey(o.key, o.name);
1185
+ const head = (what) => `${title}, ${what} (added by the ${C} layer, not in W1):`;
1186
+ const foot = `W1 page: ${opts.site}${opts.path}/ \xB7 ${C} layer: localizations/${cc.toLowerCase()}`;
1187
+ if (a.kind === "field") {
1188
+ const m = of(o, "fields", "added").find((x) => x.id === a.key);
1189
+ if (!m) return null;
1190
+ const to = m.to ?? {};
1191
+ return `${head(`field ${m.id}`)}
1192
+ | No. | Name | Type | Properties | Layer |
1193
+ |---|---|---|---|---|
1194
+ | ${m.id} | ${m.name} | ${to.type ?? ""} | ${propertiesText(to.properties)} | ${C} |
1195
+
1196
+ ${foot}
1197
+ `;
1198
+ }
1199
+ if (a.kind === "value") {
1200
+ const m = of(o, "values", "added").find((x) => x.id === a.key);
1201
+ if (!m) return null;
1202
+ return `${head(`value ${m.id}`)}
1203
+ | Ordinal | Name | Layer |
1204
+ |---|---|---|
1205
+ | ${m.id} | ${m.name} | ${C} |
1206
+
1207
+ ${foot}
1208
+ `;
1209
+ }
1210
+ const list = a.kind === "event" ? of(o, "events", "added") : of(o, "procedures", "added");
1211
+ const hits = list.filter((x) => x.name === a.key);
1212
+ if (!hits.length) return null;
1213
+ return `${head(`${a.kind === "event" ? "event" : "procedure"} ${a.key}`)}
1214
+ ${hits.map((m) => `- \`${signature(m.name, m.to ?? {})}\` (${(m.to ?? {}).event ?? (m.to ?? {}).scope ?? "global"})`).join("\n")}
1215
+
1216
+ ${foot}
1217
+ `;
1218
+ }
1219
+
915
1220
  // src/sections.ts
916
1221
  var DEFAULT_BUDGET = 3e4;
917
1222
  var MIN_BUDGET = 5e3;
@@ -1089,7 +1394,7 @@ function findSection(p, want) {
1089
1394
  }
1090
1395
  var keyList = (p, counts = false) => [...p.sections.map((s) => counts && s.rows.length ? `${s.key} (${s.rows.length})` : s.key), "frontmatter"].join(", ");
1091
1396
  var titleOf = (p, path) => p.preamble.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() || path;
1092
- function clip(s, n, mark = "\u2026") {
1397
+ function clip2(s, n, mark = "\u2026") {
1093
1398
  if (s.length <= n) return s;
1094
1399
  const end = /[\uD800-\uDBFF]/.test(s[n - 2]) ? n - 2 : n - 1;
1095
1400
  return `${s.slice(0, end)}${mark}`;
@@ -1152,7 +1457,7 @@ function compactView(md, path, budget) {
1152
1457
  const more = `\u2026 (section: "${s.key}")`;
1153
1458
  if (!s.rows.length) {
1154
1459
  const text2 = s.head.filter((l) => !blank(l)).join("\n");
1155
- if (text2) out.push(clip(text2, PROSE_CLIP, more));
1460
+ if (text2) out.push(clip2(text2, PROSE_CLIP, more));
1156
1461
  } else {
1157
1462
  const lead = s.head.find((l) => !blank(l) && !l.startsWith("|") && !l.startsWith("### "));
1158
1463
  if (lead && lead.length <= HEAD_LINE_MAX) out.push(lead);
@@ -1165,12 +1470,12 @@ function compactView(md, path, budget) {
1165
1470
  const t = r.table ? r.table.join("\n") : null;
1166
1471
  if (r.table && t !== table2) out.push(...r.table);
1167
1472
  table2 = t;
1168
- out.push(clip(r.lines[0], ROW_CLIP));
1473
+ out.push(clip2(r.lines[0], ROW_CLIP));
1169
1474
  }
1170
1475
  if (s.rows.length > PREVIEW_ROWS) out.push(`\u2192 ${s.rows.length - PREVIEW_ROWS} more of ${s.rows.length}: cat("${path}", section: "${s.key}", offset: ${PREVIEW_ROWS})`);
1171
1476
  else {
1172
1477
  const text2 = s.tail.filter((l) => !blank(l)).join("\n");
1173
- if (text2) out.push(clip(text2, PROSE_CLIP, more));
1478
+ if (text2) out.push(clip2(text2, PROSE_CLIP, more));
1174
1479
  }
1175
1480
  }
1176
1481
  out.push("");
@@ -1264,7 +1569,7 @@ ${pageView(md, clean, {}, budget, site)}`;
1264
1569
  }
1265
1570
 
1266
1571
  // src/server.ts
1267
- var VERSION = "0.5.0";
1572
+ var VERSION = "0.7.0";
1268
1573
  var SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
1269
1574
  var LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
1270
1575
  var BUDGET = budgetOf(process.env.BC_OBSERVATORY_MAX_CHARS);
@@ -1293,6 +1598,7 @@ async function pageMarkdown(path) {
1293
1598
  var pages = [];
1294
1599
  var index = null;
1295
1600
  var loadedAt = 0;
1601
+ var symbolsMajor = null;
1296
1602
  async function shard(file, sha) {
1297
1603
  const cached = join(cacheDir, `${sha}.json`);
1298
1604
  if (!LOCAL && existsSync(cached)) return readFileSync(cached, "utf8");
@@ -1320,6 +1626,7 @@ async function loadIndex() {
1320
1626
  } catch {
1321
1627
  sm = null;
1322
1628
  }
1629
+ symbolsMajor = sm?.major ?? null;
1323
1630
  if (sm) {
1324
1631
  const byPath = new Map(idx.records.map((r) => [r.id, r]));
1325
1632
  for (const [k, e] of Object.entries(sm.kinds)) {
@@ -1332,7 +1639,37 @@ async function loadIndex() {
1332
1639
  }
1333
1640
  index = idx;
1334
1641
  loadedAt = Date.now();
1642
+ countryDiffs.clear();
1643
+ }
1644
+ var countryDiffs = /* @__PURE__ */ new Map();
1645
+ function diffMajor() {
1646
+ if (symbolsMajor) return symbolsMajor;
1647
+ let best2 = 0;
1648
+ for (const r of index?.records ?? []) for (const part of (r.major ?? "").split(/[\s,]+/)) {
1649
+ const n = Number(part.split("-").pop());
1650
+ if (n > best2) best2 = n;
1651
+ }
1652
+ return best2 ? String(best2) : null;
1335
1653
  }
1654
+ function replacedObjects(major, cc) {
1655
+ const key = `${major}-${cc}`, hit = countryDiffs.get(key);
1656
+ if (hit && Date.now() - hit.at < DAY) return Promise.resolve(hit.objects);
1657
+ const loading = (async () => {
1658
+ try {
1659
+ const diff = JSON.parse(await dataFile(`code/diffs/country/${key}.json`));
1660
+ return new Map(diff.objects.filter((o) => o.change === "replaced").map((o) => [o.key, o]));
1661
+ } catch {
1662
+ return null;
1663
+ }
1664
+ })();
1665
+ countryDiffs.set(key, { at: Date.now(), objects: loading });
1666
+ void loading.then((o) => {
1667
+ const e = countryDiffs.get(key);
1668
+ if (e) e.objects = o;
1669
+ });
1670
+ return loading;
1671
+ }
1672
+ var replacedBy = async (major, cc, key) => (await replacedObjects(major, cc))?.get(key) ?? null;
1336
1673
  var url = (path) => `${SITE}${path}/`;
1337
1674
  var line = (r, tag = "") => `- ${r.title}${r.caption ? ` (captioned "${r.caption}")` : ""} [${r.type}${r.tier ? `, ${r.tier}` : ""}${r.date ? `, ${r.date}` : ""}] path=${r.path}${tag}${r.path_label ? `
1338
1675
  in: ${r.path_label}` : ""}${r.stats ? `
@@ -1396,7 +1733,7 @@ function fuse(lists, k = 60) {
1396
1733
  }
1397
1734
  function keepOf(a) {
1398
1735
  const lc = (x) => x?.trim().toLowerCase() || void 0;
1399
- const type = lc(a.type), tier = lc(a.tier), country = lc(a.country), app = lc(a.app), kind = lc(a.kind);
1736
+ const type = lc(a.type), tier = lc(a.tier), country = lc(a.country), app = lc(a.app), kind = KINDS[lc(a.kind) ?? ""] ?? lc(a.kind);
1400
1737
  const system = a.system ? systemOf(a.system) ?? lc(a.system) : void 0;
1401
1738
  const objectType = a.object_type ? typeOfWord(a.object_type.replace(/\s+/g, "")) ?? lc(a.object_type) : void 0;
1402
1739
  return (r) => (!type || r.type === type) && (!tier || r.tier === tier) && (!system || r.system === system) && (!country || (r.country ?? "") === country) && (!app || (r.app ?? "").toLowerCase() === app) && (!objectType || r.objectType === objectType) && (!kind || r.kind === kind);
@@ -1407,9 +1744,11 @@ async function toolSearch(a) {
1407
1744
  const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
1408
1745
  const q = parseQuery(a.query, { countries: countriesOf(index) });
1409
1746
  const hits = search(index, q, { filter: keep2 });
1410
- const byId = new Map(hits.map((h) => [h.r.id, h.r]));
1747
+ const kind = q.kind ?? KINDS[a.kind?.trim().toLowerCase() ?? ""] ?? null;
1748
+ const uid = (r) => r.kind !== "page" && r.country ? `${r.id} ${r.country}` : r.id;
1749
+ const byId = new Map(hits.map((h) => [uid(h.r), h.r]));
1411
1750
  let semantic = [], note = "";
1412
- if (mode !== "keyword") {
1751
+ if (mode !== "keyword" && !kind) {
1413
1752
  const m = await loadModel();
1414
1753
  if (m) {
1415
1754
  const v = embed(m, a.query), data = pageVectors(m);
@@ -1426,27 +1765,36 @@ async function toolSearch(a) {
1426
1765
  }
1427
1766
  } else if (mode === "semantic") note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
1428
1767
  }
1429
- const keyword = hits.map((h) => h.r.id);
1768
+ const keyword = hits.map((h) => uid(h.r));
1430
1769
  let order, exactBand = 0;
1431
1770
  if (mode === "semantic") order = semantic.length ? semantic : keyword.slice(0, POOL);
1432
1771
  else if (mode === "keyword" || !semantic.length) order = keyword;
1433
1772
  else {
1434
- const exact = hits.filter((h) => h.band === "exact").map((h) => h.r.id), pinned = new Set(exact);
1773
+ const exact = hits.filter((h) => h.band === "exact").map((h) => uid(h.r)), pinned = new Set(exact);
1435
1774
  exactBand = exact.length;
1436
- order = [...exact, ...fuse([hits.filter((h) => h.band !== "exact").slice(0, POOL).map((h) => h.r.id), semantic.filter((p) => !pinned.has(p))])];
1775
+ order = [...exact, ...fuse([hits.filter((h) => h.band !== "exact").slice(0, POOL).map((h) => uid(h.r)), semantic.filter((p) => !pinned.has(p))])];
1437
1776
  }
1438
1777
  if (mode !== "semantic" && mode !== "keyword" && !semantic.length) exactBand = hits.filter((h) => h.band === "exact").length;
1439
1778
  const inKeyword = new Set(keyword.slice(0, Math.max(POOL, limit))), inMeaning = new Set(semantic);
1440
1779
  const tag = (id) => inKeyword.has(id) && inMeaning.has(id) ? " [both]" : inMeaning.has(id) ? " [meaning]" : " [keyword]";
1441
1780
  const shown = order.slice(0, limit).map((id) => byId.get(id)).filter(Boolean);
1442
- const hs = hints(index, q, hits).filter((h) => !hits.length || hits[0].s < 6 || q.question || !!q.ref || /: no page; /.test(h.text));
1781
+ const filters = ["type", "tier", "system", "country", "app", "object_type"].filter((k) => a[k]?.trim()).map((k) => `${k}=${a[k].trim()}`).join(", ");
1782
+ const unfiltered = kind && !hits.length && filters ? search(index, q, { filter: (r) => r.kind === kind }) : void 0;
1783
+ const hs = hints(index, q, hits, { kind, unfiltered, filters, keep: keep2 }).filter((h) => !hits.length || hits[0].s < 6 || q.question || !!q.ref || /: no page; /.test(h.text));
1443
1784
  const hintText = hs.length ? `
1444
1785
  ${hs.map((h) => `Hint: ${h.text}${h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : ""}`).join("\n")}` : "";
1786
+ if (!shown.length && kind && /^No \w+ named /.test(hs[0]?.text ?? "")) {
1787
+ const [first, ...more] = hs, via = (h) => h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : "";
1788
+ return `${first.text}${via(first)}${note}${more.map((h) => `
1789
+ Hint: ${h.text}${via(h)}`).join("")}`;
1790
+ }
1445
1791
  if (!shown.length) return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.${note}${hintText}`;
1446
1792
  const how = semantic.length && keyword.length && mode === "hybrid" ? "keyword + meaning" : mode === "semantic" && semantic.length ? "meaning" : "keyword";
1447
1793
  const learnNote = shown.some((r) => r.type === "learn") ? "; a Learn article's url opens on learn.microsoft.com, cat(hub) reads its hub" : "";
1448
- return `${shown.length} results, ${how}${note}${how === "keyword + meaning" || mode === "hybrid" ? ` (exact band: ${exactBand})` : ""} (read one with cat(path)${learnNote}):
1449
- ${shown.map((r) => hitLine(r, tag(r.id))).join("\n")}${hintText}`;
1794
+ if (mode === "hybrid" && !keyword.length && semantic.length) return `No keyword match for "${a.query}"; ${shown.length} pages by meaning only (read one with cat(path)${learnNote}):
1795
+ ${shown.map((r) => hitLine(r, tag(uid(r)))).join("\n")}${hintText}`;
1796
+ return `${shown.length} results, ${how}${note}${how === "keyword + meaning" ? ` (exact band: ${exactBand})` : ""} (read one with cat(path)${learnNote}):
1797
+ ${shown.map((r) => hitLine(r, tag(uid(r)))).join("\n")}${hintText}`;
1450
1798
  }
1451
1799
  async function toolLs(a) {
1452
1800
  await loadIndex();
@@ -1468,7 +1816,27 @@ async function toolLs(a) {
1468
1816
  async function toolCat(a) {
1469
1817
  const [base, anchor] = a.path.split(/(?=#)/);
1470
1818
  const clean = `${base.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "")}${anchor ?? ""}`;
1471
- return pageView(await pageMarkdown(clean.replace(/#.*$/, "")), clean, a, BUDGET, SITE);
1819
+ const path = clean.replace(/#.*$/, ""), md = await pageMarkdown(path);
1820
+ if (anchor && anchorView(md, path, anchor, SITE) === null) {
1821
+ const added = await layerMember(path, anchor);
1822
+ if (added) return added;
1823
+ }
1824
+ return pageView(md, clean, a, BUDGET, SITE);
1825
+ }
1826
+ async function layerMember(path, anchor) {
1827
+ const want = parseAnchor(anchor), major = diffMajor();
1828
+ if (!want || !major) return null;
1829
+ await loadIndex();
1830
+ const id = `${path}${anchor}`;
1831
+ const layers = [...new Set(index.records.filter((r) => r.kind !== "page" && r.id === id && r.country && r.app === `${r.country.toUpperCase()} layer`).map((r) => r.country))].sort();
1832
+ const owner = index.records.find((r) => r.kind === "page" && r.id === path);
1833
+ const out = [];
1834
+ for (const cc of layers) {
1835
+ const o = owner?.objectId != null ? await replacedBy(major, cc, `${owner.objectType}/${owner.objectId}`) : null;
1836
+ const text2 = o && memberAnswer(o, want, cc, { title: owner?.title, site: SITE, path });
1837
+ if (text2) out.push(text2);
1838
+ }
1839
+ return out.length ? out.join("\n") : null;
1472
1840
  }
1473
1841
  async function toolGetObject(a) {
1474
1842
  await loadIndex();
@@ -1503,12 +1871,24 @@ async function toolGetObject(a) {
1503
1871
  const pick = country ? inCountry.length ? inCountry : w1 : w1.length ? w1 : found;
1504
1872
  if (pick.length === 1) {
1505
1873
  const twins = found.filter((r) => r !== pick[0] && r.country).map((r) => r.country.toUpperCase());
1506
- const note = country && !inCountry.length ? `(${country.toUpperCase()} has no ${type} of its own here; the W1 ${type} applies.)
1874
+ const major = diffMajor(), key = `${type}/${pick[0].objectId ?? pick[0].name.toLowerCase()}`;
1875
+ let note = "";
1876
+ if (country && !inCountry.length) {
1877
+ const replaced = major ? await replacedBy(major, country, key) : null;
1878
+ note = replaced && major ? `${countryBlock(replaced, country, major, { title: pick[0].title })}
1507
1879
 
1508
- ` : "";
1509
- const also = !country && twins.length ? `
1880
+ ` : `(${country.toUpperCase()} has no ${type} of its own here; the W1 ${type} applies.)
1881
+
1882
+ `;
1883
+ }
1884
+ let also = !country && twins.length ? `
1510
1885
 
1511
1886
  (Also in ${twins.join(", ")}: get_object("${type}", "${pick[0].objectId ?? pick[0].name}", country).)` : "";
1887
+ if (!country && !pick[0].country && major && !a.section) {
1888
+ const layers = (await Promise.all(countriesOf(index).map(async (cc2) => ({ cc: cc2, object: await replacedBy(major, cc2, key) })))).filter((l) => !!l.object);
1889
+ const line2 = replacedInLine(layers);
1890
+ if (line2) also += `${also ? "\n" : "\n\n"}${line2}`;
1891
+ }
1512
1892
  return `${note}${pageView(await pageMarkdown(pick[0].id), pick[0].id, a, BUDGET, SITE)}${a.section ? "" : also}`;
1513
1893
  }
1514
1894
  if (pick.length > 1) return `${pick.length} ${type} objects match "${a.idOrName}"; read one with cat(path):
@@ -1572,7 +1952,7 @@ function createServer() {
1572
1952
  "search",
1573
1953
  {
1574
1954
  title: "Search the knowledge base",
1575
- description: "Search every page, and the fields, published events, global procedures and enum values of AL objects, ranked like the site's search. Takes names ('Sales Header'), references ('t36', 'cu 80', 'table 11300 BE'), bare ids ('36') and symbol prefixes ('event: OnAfterPost', 'field: Posting Date', 'proc: CopyToTempLines'); a symbol hit's path ends in its anchor (#event-<Name>, #field-<id>, #proc-<Name>, #value-<ordinal>); cat(path) reads that member alone. Learn articles too: type learn, a url, not a path. Hybrid by default: exact matches stay on top and the rest is fused with a meaning ranking; each hit says [keyword], [meaning] or [both]; mode 'keyword' or 'semantic' for one list. Filters: type (topic, learn, app, feature, object, localization, video, post, change, source, digest, release), tier (official, community), system (finance, sales, ... or an alias such as 'g/l'), country ('BE'), app ('Subscription Billing'), object_type ('table'), kind (page, field, event, proc, value).",
1955
+ description: "Search every page and the fields, events, global procedures and enum values of AL objects. Takes names ('Sales Header'), references ('t36', 'cu 80'), bare ids ('36') and symbol prefixes ('event: OnAfterPost', 'proc: CopyToTempLines'); symbols rank by their owner's layer and importance; a kind: query with no hit names the nearest symbols; a symbol hit's path ends in its anchor (#event-<Name>, #field-<id>, #proc-<Name>, #value-<ordinal>); cat(path) reads that member. Learn articles: type learn, a url not a path. Hybrid by default: exact matches on top, the rest fused with a meaning ranking; each hit says [keyword], [meaning] or [both]; mode 'keyword' or 'semantic' for one list. Filters: type (topic, learn, app, feature, object, localization, video, post, change, source, digest, release), tier (official, community), system (finance, sales, ... or alias 'g/l'), country ('BE'), app ('Subscription Billing'), object_type ('table'), kind (page, field, event, proc, value).",
1576
1956
  inputSchema: { query: z.string(), type: z.string().optional(), tier: z.string().optional(), system: z.string().optional(), country: z.string().optional(), app: z.string().optional(), object_type: z.string().optional(), kind: z.enum(["page", "field", "event", "proc", "value"]).optional(), limit: z.number().int().min(1).max(50).optional(), mode: z.enum(["hybrid", "keyword", "semantic"]).optional() }
1577
1957
  },
1578
1958
  async (a) => text(await toolSearch(a))
@@ -1589,7 +1969,7 @@ function createServer() {
1589
1969
  );
1590
1970
  server.registerTool("get_object", {
1591
1971
  title: "Look up an AL object",
1592
- description: `An AL object of W1, a Microsoft first-party app or a country layer by type and id, reference, name or caption: ('table', '18'), ('table', 't36'), ('table', '36-be'), ('codeunit', 'Sales-Post'), ('page', 'Customers'), or with country 'BE'. Returns its page: fields, keys, events, procedures, calls, obsolete state, versions, countries, Learn pages; a W1 object wins over its country twins unless a country is given, and two candidates are listed with their paths. A page over ${B} characters comes back compact (frontmatter, the first 5 rows of each section, the section keys): pass section ('fields', 'events', 'procedures', 'calls', ...) with offset and limit to read one. To check that a field, event or procedure exists, search('event: <Name>') answers without the page.`,
1972
+ description: `An AL object of W1, a Microsoft first-party app or a country layer by type and id, reference, name or caption: ('table', '18'), ('table', 't36'), ('table', '36-be'), ('codeunit', 'Sales-Post'), ('page', 'Customers'), or with country 'BE' (what that layer adds prints first). Returns its page: fields, keys, events, procedures, calls, obsolete state, versions, countries, Learn pages; a W1 object wins over its country twins unless a country is given, and two candidates are listed with their paths. A page over ${B} characters comes back compact (frontmatter, the first 5 rows of each section, the section keys): pass section ('fields', 'events', 'procedures', 'calls', ...) with offset and limit to read one. To check that a field, event or procedure exists, search('event: <Name>') answers without the page. counts.procedures counts every procedure (public, local, events, subscribers); the summary's 'public procedures' excludes locals and events.`,
1593
1973
  inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional(), ...VIEW }
1594
1974
  }, async (a) => text(await toolGetObject(a)));
1595
1975
  server.registerTool("diff_object", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bc-observatory",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for BC Observatory: an agent-first, cross-referenced knowledge base of Microsoft Dynamics 365 Business Central (Learn, code, roadmap, videos, community blogs).",
5
5
  "type": "module",
6
6
  "bin": {