bc-observatory 0.5.0 → 0.7.1

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 +3 -3
  2. package/dist/server.js +454 -72
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@ Microsoft 365 roadmap features, videos and community posts), every claim with a
8
8
  claude mcp add bc-observatory -- npx -y bc-observatory@latest
9
9
  ```
10
10
 
11
- Or the Claude Code plugin (MCP server plus skills): `claude plugin marketplace add waldo1001/waldo.BCObservatory`.
11
+ Or the Claude Code plugin (MCP server plus skills): `claude plugin marketplace add waldo1001/waldo.BCObservatory`. The server never fetches Learn pages (D01); the plugin also connects Microsoft's Learn MCP (`https://learn.microsoft.com/api/mcp`), and a Learn hit's url is read there with `microsoft_docs_fetch`.
12
12
 
13
13
  Tools: `search`, `ls`, `cat`, `get_object`, `diff_object`, `localization`, `whats_new`, `blog_footprint`, `feedback`.
14
14
 
@@ -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,
@@ -387,6 +463,7 @@ var push = (m, k, v) => {
387
463
  if (l) l.push(v);
388
464
  else m.set(k, [v]);
389
465
  };
466
+ var INDEX_PAGE_IDS = 16;
390
467
  function normOf(r) {
391
468
  const cached = NORM.get(r);
392
469
  if (cached) return cached;
@@ -395,12 +472,21 @@ function normOf(r) {
395
472
  const own = r.type === "topic" ? new Set(tokenize(r.title)) : null;
396
473
  const learn = r.type === "learn";
397
474
  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)];
475
+ const coded = learn ? [...new Set((r.tags ?? []).filter(isCodedId))] : [];
476
+ const eqs = r.type === "object" || symbol ? [tokenize(r.name), ...r.caption ? [tokenize(r.caption)] : []] : [tokenize(r.title), ...learn ? [...(r.title.match(/\b[A-Z]{2,3}\d{4}\b/g) ?? []).filter(isCodedId), ...coded.length <= INDEX_PAGE_IDS ? coded : []].map((t) => tokenize(t)).filter((t) => t.length === 1) : []];
399
477
  const n = { main: [...main], tags: learn ? tags : [...new Set(tags)], text: symbol ? null : indexTokens(r.text ?? ""), eqs };
400
478
  NORM.set(r, n);
401
479
  return n;
402
480
  }
403
481
  var textOf = (r, n) => n.text ??= indexTokens(r.text ?? "");
482
+ function seqs(r) {
483
+ let main, text2;
484
+ const symbol = r.kind !== "page";
485
+ return {
486
+ main: () => main ??= [seqTokens(r.name, symbol), seqTokens(r.title, symbol), ...r.caption ? [seqTokens(r.caption)] : []].flatMap((f, k) => k ? ["", ...f] : f),
487
+ text: () => text2 ??= seqTokens(r.text ?? "")
488
+ };
489
+ }
404
490
  function prepare(records, prev) {
405
491
  const index2 = prev ?? { records: [], postings: /* @__PURE__ */ new Map(), vocab: /* @__PURE__ */ new Map(), byType: /* @__PURE__ */ new Map(), maxInbound: /* @__PURE__ */ new Map() };
406
492
  let st = STATE.get(index2);
@@ -522,7 +608,7 @@ function matchersOf(index2, q) {
522
608
  }
523
609
  return m;
524
610
  }
525
- function best(m, toks) {
611
+ function best(m, toks, seq = () => toks) {
526
612
  let b = null;
527
613
  for (const t of toks) {
528
614
  const f = m.words.get(t);
@@ -530,7 +616,8 @@ function best(m, toks) {
530
616
  }
531
617
  for (const p of m.phrases) {
532
618
  if (b && b.f >= p.f) continue;
533
- const f = phraseForm(p.words, toks);
619
+ if (!p.words.every((w) => holds(toks, w))) continue;
620
+ const f = phraseForm(p.words, seq());
534
621
  if (f) {
535
622
  const v = p.f * f;
536
623
  if (!b || v > b.f) b = { f: v, how: p.how, ...p.via ? { via: p.via } : {} };
@@ -538,6 +625,21 @@ function best(m, toks) {
538
625
  }
539
626
  return b;
540
627
  }
628
+ var STEMS = /* @__PURE__ */ new Map();
629
+ var stemOf = (t) => {
630
+ let s = STEMS.get(t);
631
+ if (s === void 0) {
632
+ s = stem(t);
633
+ STEMS.set(t, s);
634
+ }
635
+ return s;
636
+ };
637
+ function holds(toks, w) {
638
+ if (toks.includes(w)) return true;
639
+ const sw = stemOf(w);
640
+ for (const t of toks) if (t[0] === w[0] && stemOf(t) === sw) return true;
641
+ return false;
642
+ }
541
643
  function phraseForm(words, toks) {
542
644
  let bestF = 0;
543
645
  for (let i = 0; i + words.length <= toks.length; i++) {
@@ -551,7 +653,7 @@ function phraseForm(words, toks) {
551
653
  }
552
654
  return bestF;
553
655
  }
554
- function nameMatch(eq, ms, plural) {
656
+ function nameMatch(eq, ms, plural2) {
555
657
  let pos = 0, f = 1, lastPrefix = false;
556
658
  for (const m of ms) {
557
659
  const ws = m.term.phrase ? m.term.text.split(" ") : null;
@@ -559,7 +661,7 @@ function nameMatch(eq, ms, plural) {
559
661
  for (const w of ws) {
560
662
  const t2 = eq[pos++];
561
663
  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);
664
+ f = Math.min(f, t2 === w ? 1 : stem(t2) === stem(w) ? plural2 ? 1 : 0.75 : 0);
563
665
  }
564
666
  if (!f) return { eq: 0, starts: 0 };
565
667
  lastPrefix = false;
@@ -576,10 +678,10 @@ function nameMatch(eq, ms, plural) {
576
678
  lastPrefix = p.how === "prefix";
577
679
  continue;
578
680
  }
579
- f = Math.min(f, form.how === "stem" && plural ? 1 : form.f);
681
+ f = Math.min(f, form.how === "stem" && plural2 ? 1 : form.f);
580
682
  lastPrefix = form.how === "prefix";
581
683
  }
582
- if (pos === eq.length && !lastPrefix && (plural || f === 1)) return { eq: f, starts: 0 };
684
+ if (pos === eq.length && !lastPrefix && (plural2 || f === 1)) return { eq: f, starts: 0 };
583
685
  return { eq: 0, starts: pos <= eq.length ? f : 0 };
584
686
  }
585
687
  var NARRATIVE = { reviewed: 2, unreviewed: 0, none: -2, derived: -2 };
@@ -600,7 +702,7 @@ function scoreRecord(index2, r, q) {
600
702
  if (q.ref.country) return { s: country === q.ref.country ? 1e3 : country === null ? 900 : 850, why };
601
703
  return { s: country === null ? 1e3 : 900, why };
602
704
  }
603
- const prior = object ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
705
+ const prior = objectish ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
604
706
  const lift = (base) => base + (object ? LAYER[r.layer] : symbol ? 0 : 1) + (objectish ? 2 * r.importance : 0) + prior;
605
707
  if (q.number !== null) {
606
708
  if (!object || r.objectId !== q.number) return none;
@@ -616,10 +718,10 @@ function scoreRecord(index2, r, q) {
616
718
  if (object && country && q.countries.includes(country)) return { s: 3 + 2 * r.importance + prior, why };
617
719
  return none;
618
720
  }
619
- const n = normOf(r), ms = matchersOf(index2, q);
620
- let s = 0, named = false, gate = !symbol || !!q.kind;
721
+ const n = normOf(r), ms = matchersOf(index2, q), sq = seqs(r);
722
+ let s = 0, textPts = 0, named = false, gate = !symbol || !!q.kind;
621
723
  for (const m of ms) {
622
- const main = best(m, n.main), tags = best(m, n.tags), text2 = best(m, textOf(r, n));
724
+ const main = best(m, n.main, sq.main), tags = best(m, n.tags), text2 = best(m, textOf(r, n), sq.text);
623
725
  if (!main && !tags && !text2) return none;
624
726
  if (main || tags) named = true;
625
727
  if (symbol && !gate && n.eqs[0].some((t) => {
@@ -628,21 +730,25 @@ function scoreRecord(index2, r, q) {
628
730
  })) gate = true;
629
731
  if (symbol && /^on[a-z]/.test(m.term.text)) gate = true;
630
732
  s += 3 * (main?.f ?? 0) + 2 * (tags?.f ?? 0) + (text2?.f ?? 0);
733
+ textPts += text2?.f ?? 0;
631
734
  const via = [main, tags, text2].find((x) => x?.via && (x.how === "syn" || x.how === "fuzzy"))?.via;
632
735
  if (via && !why.includes(`matched "${via}"`)) why.push(`matched "${via}"`);
633
736
  }
634
737
  if (!gate) return none;
738
+ const learn = r.type === "learn";
635
739
  let eq = 0, starts = 0, byName = false;
636
740
  n.eqs.forEach((e, k) => {
637
741
  const x = nameMatch(e, ms, objectish);
638
742
  if (x.eq > eq) {
639
743
  eq = x.eq;
640
- byName = k === 0;
744
+ byName = k === 0 || learn;
641
745
  }
642
- starts = Math.max(starts, x.starts);
746
+ if (!learn || k === 0) starts = Math.max(starts, x.starts);
643
747
  });
748
+ 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 ")));
749
+ const owned = symbol && !!lifted;
644
750
  if (eq) {
645
- s += 10 * eq;
751
+ s += 10 * eq - (owned ? textPts : 0);
646
752
  why.push(!byName ? "caption" : eq >= 0.75 ? "name" : "name~");
647
753
  } else if (starts) {
648
754
  s += 3 * starts;
@@ -652,8 +758,7 @@ function scoreRecord(index2, r, q) {
652
758
  if ((r.type === "topic" || r.type === "app") && !symbol) s += NARRATIVE[r.narrative ?? (r.type === "app" ? "none" : "unreviewed")] ?? 0;
653
759
  s += countryPoints(q, r, country);
654
760
  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 ")));
761
+ s = symbol ? s + 2 * r.importance + (owned ? LAYER[r.layer] + prior : 0) : lift(s);
657
762
  if (objectish && !lifted) {
658
763
  s *= 0.6;
659
764
  why.push("demoted");
@@ -695,11 +800,11 @@ function search(index2, q, opts = {}) {
695
800
  return opts.limit ? hits.slice(0, opts.limit) : hits;
696
801
  }
697
802
  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}"` : ""}`;
803
+ const of2 = `${r.owner ? ` of ${r.owner.title}` : ""}${r.country && r.app === `${r.country.toUpperCase()} layer` ? ` \xB7 ${r.app}` : ""}`, x = r.extra ?? {};
804
+ if (r.kind === "field") return `field ${x.id}${of2} \xB7 ${x.type}`;
805
+ if (r.kind === "event") return `event${of2} \xB7 ${x.kind} \xB7 ${x.subscribers} ${x.subscribers === 1 ? "subscriber" : "subscribers"}${x.obsolete ? ` \xB7 obsolete ${x.obsolete}` : ""}`;
806
+ 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}` : ""}`;
807
+ if (r.kind === "value") return `value ${x.ordinal}${of2}${x.caption && x.caption !== r.name ? ` \xB7 "${x.caption}"` : ""}`;
703
808
  return r.title;
704
809
  }
705
810
  function objectsAt(index2, type, id) {
@@ -720,7 +825,8 @@ function typoWords(index2, q) {
720
825
  function namedBy(index2, r, q) {
721
826
  if (!STATE.get(index2)) return 0;
722
827
  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);
828
+ const sq = seqs(r);
829
+ return matchersOf(index2, q).reduce((a, m) => a + (best(m, n.main, sq.main)?.f ?? best(m, n.tags)?.f ?? 0), 0);
724
830
  }
725
831
  function symbolRecords(kind, rows, ownerOf) {
726
832
  const out = [];
@@ -736,7 +842,7 @@ function countriesOf(index2) {
736
842
  }
737
843
 
738
844
  // ../search/src/hints.ts
739
- function hints(index2, q, hits) {
845
+ function hints(index2, q, hits, opts = {}) {
740
846
  const out = [];
741
847
  const unknown = q.warnings.map((w) => /^unknown abbreviation (\w+)$/.exec(w)?.[1]).find(Boolean);
742
848
  const digits = /(\d+)\s*$/.exec(q.raw)?.[1];
@@ -768,21 +874,89 @@ function hints(index2, q, hits) {
768
874
  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
875
  }
770
876
  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;
877
+ const hubOf = () => {
878
+ let hub = null, best2 = 0;
879
+ for (const i of index2.byType.get("topic") ?? []) {
880
+ const r = index2.records[i], named = namedBy(index2, r, q);
881
+ if (!named) continue;
882
+ const s = 3 * named + Math.log2((r.members ?? 0) + 1) + (r.narrative === "reviewed" ? 2 : r.narrative === "none" ? -2 : 0);
883
+ if (s > best2) {
884
+ best2 = s;
885
+ hub = r;
886
+ }
779
887
  }
888
+ return hub;
889
+ };
890
+ if (q.shape === "what-changed" || !q.shape) {
891
+ const hub = hubOf();
892
+ 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 } : {} });
893
+ } else {
894
+ const hub = learnHub(index2, hits) ?? hubOf();
895
+ if (hub) out.push({ text: `For ${q.shape === "how-to" ? "how-to" : "a concept"}, start with the hub: ${hub.title}.`, path: hub.id });
896
+ }
897
+ }
898
+ const kind = q.kind ?? opts.kind ?? null;
899
+ if (kind && !hits.length) {
900
+ const name = nameOf2(q);
901
+ if (opts.unfiltered?.length) {
902
+ const n = opts.unfiltered.length;
903
+ out.unshift({ text: `No ${KIND_WORD[kind]} named "${name}" matches the filters${opts.filters ? ` (${opts.filters})` : ""}; without them: ${n} ${n === 1 ? "hit" : "hits"}.` });
904
+ } else {
905
+ const h = nearestSymbols(index2, kind, name, opts.keep);
906
+ if (h) out.unshift(h);
780
907
  }
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
908
  }
783
- void hits;
784
909
  return out;
785
910
  }
911
+ function learnHub(index2, hits) {
912
+ for (const h of hits.slice(0, 10)) {
913
+ const path = h.r.type === "learn" ? h.r.page?.hub : void 0;
914
+ if (!path) continue;
915
+ const hub = (index2.byType.get("topic") ?? []).map((i) => index2.records[i]).find((r) => r.id === path);
916
+ if (hub) return hub;
917
+ }
918
+ return null;
919
+ }
920
+ var KIND_WORD = { field: "field", event: "event", proc: "procedure", value: "value" };
921
+ function nameOf2(q) {
922
+ const typed = q.terms.map((t) => t.raw || t.text).join(" ").replace(/"/g, "").trim();
923
+ return typed || (q.kind ? q.raw.replace(/^\s*\w+\s*:\s*/, "").replace(/"/g, "").trim() : "");
924
+ }
925
+ function nearestSymbols(index2, kind, name, keep2) {
926
+ const all = index2.byType.get(kind) ?? [];
927
+ const want = name.toLowerCase();
928
+ if (!all.length || !want) return null;
929
+ const word = KIND_WORD[kind], head1 = `No ${word} named "${name}".`;
930
+ const near = [];
931
+ if (want.length >= 4) {
932
+ 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)));
933
+ for (const i of all) {
934
+ const r = index2.records[i], n = r.name.toLowerCase();
935
+ if (n === want) return null;
936
+ if (keep2 && !keep2(r)) continue;
937
+ let ok = editDistance(want, n, reach) <= reach;
938
+ if (!ok && n.startsWith(head)) {
939
+ let k = 0;
940
+ while (k < n.length && k < want.length && n[k] === want[k]) k++;
941
+ ok = k >= shared;
942
+ }
943
+ if (ok) near.push({ r, d: editDistance(want, n, 60) });
944
+ }
945
+ } else if (all.some((i) => index2.records[i].name.toLowerCase() === want)) return null;
946
+ 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));
947
+ const seen = /* @__PURE__ */ new Set(), pick = [];
948
+ for (const { r } of near) {
949
+ if (pick.length >= 3) break;
950
+ if (seen.has(r.id)) continue;
951
+ seen.add(r.id);
952
+ pick.push(r);
953
+ }
954
+ if (!pick.length) return { text: head1 };
955
+ const plural2 = word === "procedure" ? "procedures" : `${word}s`;
956
+ const label = (r) => r.owner ? `${r.name} (${r.owner.title})` : r.name;
957
+ const qk = kind === "proc" ? "proc" : kind;
958
+ return { text: `${head1} Nearest ${plural2}: ${pick.map(label).join(", ")}.`, query: `${qk}: ${pick[0].name}` };
959
+ }
786
960
 
787
961
  // ../search/src/learn.ts
788
962
  function learnRecords(doc) {
@@ -912,6 +1086,139 @@ function dot(a, b, bOffset = 0) {
912
1086
  return s;
913
1087
  }
914
1088
 
1089
+ // src/country.ts
1090
+ var BLOCK_MEMBERS = 40;
1091
+ var DELTA_CLIP = 140;
1092
+ var KINDS2 = ["fields", "values", "events", "procedures", "keys"];
1093
+ var NOUN = { fields: ["field", "fields"], values: ["value", "values"], events: ["event", "events"], procedures: ["procedure", "procedures"], keys: ["key", "keys"] };
1094
+ var plural = (n, kind) => `${n} ${NOUN[kind][n === 1 ? 0 : 1]}`;
1095
+ var of = (o, kind, change) => (o[kind] ?? []).filter((m) => m.change === change);
1096
+ var clip = (s, n = DELTA_CLIP) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
1097
+ function titleOfKey(key, name) {
1098
+ const [type, id] = key.split("/");
1099
+ return `${type.charAt(0).toUpperCase()}${type.slice(1)} ${id} "${name}"`;
1100
+ }
1101
+ function addedSummary(o) {
1102
+ 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(", ");
1103
+ }
1104
+ function replacedInLine(layers) {
1105
+ if (!layers.length) return null;
1106
+ const parts2 = [...layers].sort((a, b) => a.cc.localeCompare(b.cc)).map(({ cc, object }) => {
1107
+ const s = addedSummary(object);
1108
+ return `${cc.toUpperCase()}${s ? ` (${s})` : ""}`;
1109
+ });
1110
+ return `Replaced in: ${parts2.join(", ")}.`;
1111
+ }
1112
+ var paramsOf = (ps) => (ps ?? []).map((p) => `${p.var ? "var " : ""}${p.name}: ${p.type}`).join("; ");
1113
+ var signature = (name, to) => `${name}(${paramsOf(to.params)})${to.returns ? `: ${to.returns}` : ""}`;
1114
+ var procLine = (m) => {
1115
+ const to = m.to ?? {};
1116
+ return `${signature(m.name, to)} (${to.event && to.event !== "subscriber" ? to.event : to.scope ?? "global"})${to.obsolete?.state ? ` [obsolete ${to.obsolete.state}]` : ""}`;
1117
+ };
1118
+ var fieldLine = (m) => `${m.id} "${m.name}" ${(m.to ?? {}).type ?? ""}`.trimEnd();
1119
+ function propertiesText(p) {
1120
+ return Object.entries(p ?? {}).map(([k, v]) => v === "true" ? k : /^(Caption|ToolTip)/.test(k) ? `${k} "${v}"` : `${k} ${v}`).join("; ");
1121
+ }
1122
+ function deltaText(d) {
1123
+ if (!d) return "";
1124
+ const show = (v) => typeof v === "string" ? v : JSON.stringify(v);
1125
+ const parts2 = [];
1126
+ for (const [k, v] of Object.entries(d)) {
1127
+ if (k === "properties") {
1128
+ for (const [pk, pv] of Object.entries(v)) parts2.push(`${pk}: ${pv[0] ?? "-"} -> ${pv[1] ?? "-"}`);
1129
+ continue;
1130
+ }
1131
+ const [a, b] = v;
1132
+ if (Array.isArray(a) && Array.isArray(b)) {
1133
+ const add = b.filter((x) => !a.includes(x)), rem = a.filter((x) => !b.includes(x));
1134
+ parts2.push(`${k} ${[...add.map((x) => `+${x}`), ...rem.map((x) => `-${x}`)].join(", ") || "reordered"}`);
1135
+ } else parts2.push(`${k}: ${show(a ?? "-")} -> ${show(b ?? "-")}`);
1136
+ }
1137
+ return clip(parts2.join("; "));
1138
+ }
1139
+ function keyLine(m) {
1140
+ if (m.change === "added") return `${m.name} (new: ${((m.to ?? {}).fields ?? []).join(", ")})`;
1141
+ if (m.change === "removed") return `${m.name} (removed)`;
1142
+ return `${m.name} (${deltaText(m.delta) || "changed"})`.replace(/\(fields /, "(");
1143
+ }
1144
+ function headline(o, cc, major, title) {
1145
+ const added = addedSummary(o);
1146
+ const other = [];
1147
+ for (const k of KINDS2) {
1148
+ const changed = of(o, k, "changed").length + (k === "keys" ? of(o, k, "added").length : 0), removed = of(o, k, "removed").length;
1149
+ if (changed) other.push(`${plural(changed, k)} changed`);
1150
+ if (removed) other.push(`${plural(removed, k)} removed`);
1151
+ }
1152
+ if (o.properties?.length) other.push(`${o.properties.length} ${o.properties.length === 1 ? "property" : "properties"} changed`);
1153
+ const tail = [added || "no member added", ...other.length ? [other.join(", ")] : []].join("; ");
1154
+ return `${cc.toUpperCase()} replaces ${title} (BC${major}): ${tail}.`;
1155
+ }
1156
+ function countryBlock(o, cc, major, opts = {}) {
1157
+ const C = cc.toUpperCase(), title = opts.title ?? titleOfKey(o.key, o.name);
1158
+ const groups = [
1159
+ ["Fields added", of(o, "fields", "added").map(fieldLine)],
1160
+ ["Values added", of(o, "values", "added").map((m) => `${m.id} ${m.name}`)],
1161
+ ["Events added", of(o, "events", "added").map(procLine)],
1162
+ ["Procedures added", of(o, "procedures", "added").map(procLine)],
1163
+ ["Keys changed", [...of(o, "keys", "changed"), ...of(o, "keys", "added"), ...of(o, "keys", "removed")].map(keyLine)],
1164
+ ["Fields changed", of(o, "fields", "changed").map((m) => `${m.id} "${m.name}"${m.delta ? ` (${deltaText(m.delta)})` : ""}`)],
1165
+ ["Events changed", of(o, "events", "changed").map((m) => `${m.name}${m.delta ? ` (${deltaText(m.delta)})` : ""}`)],
1166
+ ["Procedures changed", of(o, "procedures", "changed").map((m) => `${m.name}${m.delta ? ` (${deltaText(m.delta)})` : ""}`)]
1167
+ ];
1168
+ const out = [headline(o, cc, major, title)];
1169
+ let room = BLOCK_MEMBERS, more = 0;
1170
+ for (const [label, items] of groups) {
1171
+ if (!items.length) continue;
1172
+ const shown = items.slice(0, Math.max(0, room));
1173
+ room -= shown.length;
1174
+ more += items.length - shown.length;
1175
+ if (shown.length) out.push(`${label}: ${shown.join("; ")}`);
1176
+ }
1177
+ if (more) out.push(`... and ${more} more: localization("${C}", section: "w1_objects_this_country_changes")`);
1178
+ out.push(`Source: data/code/diffs/country/${major}-${cc.toLowerCase()}.json; the ${C} page: localizations/${cc.toLowerCase()}.`);
1179
+ return out.join("\n");
1180
+ }
1181
+ function parseAnchor(anchor) {
1182
+ const m = /^#?(field|value|event|proc)-(.+)$/.exec(anchor);
1183
+ return m ? { kind: m[1], key: m[2] } : null;
1184
+ }
1185
+ function memberAnswer(o, a, cc, opts) {
1186
+ const C = cc.toUpperCase(), title = opts.title ?? titleOfKey(o.key, o.name);
1187
+ const head = (what) => `${title}, ${what} (added by the ${C} layer, not in W1):`;
1188
+ const foot = `W1 page: ${opts.site}${opts.path}/ \xB7 ${C} layer: localizations/${cc.toLowerCase()}`;
1189
+ if (a.kind === "field") {
1190
+ const m = of(o, "fields", "added").find((x) => x.id === a.key);
1191
+ if (!m) return null;
1192
+ const to = m.to ?? {};
1193
+ return `${head(`field ${m.id}`)}
1194
+ | No. | Name | Type | Properties | Layer |
1195
+ |---|---|---|---|---|
1196
+ | ${m.id} | ${m.name} | ${to.type ?? ""} | ${propertiesText(to.properties)} | ${C} |
1197
+
1198
+ ${foot}
1199
+ `;
1200
+ }
1201
+ if (a.kind === "value") {
1202
+ const m = of(o, "values", "added").find((x) => x.id === a.key);
1203
+ if (!m) return null;
1204
+ return `${head(`value ${m.id}`)}
1205
+ | Ordinal | Name | Layer |
1206
+ |---|---|---|
1207
+ | ${m.id} | ${m.name} | ${C} |
1208
+
1209
+ ${foot}
1210
+ `;
1211
+ }
1212
+ const list = a.kind === "event" ? of(o, "events", "added") : of(o, "procedures", "added");
1213
+ const hits = list.filter((x) => x.name === a.key);
1214
+ if (!hits.length) return null;
1215
+ return `${head(`${a.kind === "event" ? "event" : "procedure"} ${a.key}`)}
1216
+ ${hits.map((m) => `- \`${signature(m.name, m.to ?? {})}\` (${(m.to ?? {}).event ?? (m.to ?? {}).scope ?? "global"})`).join("\n")}
1217
+
1218
+ ${foot}
1219
+ `;
1220
+ }
1221
+
915
1222
  // src/sections.ts
916
1223
  var DEFAULT_BUDGET = 3e4;
917
1224
  var MIN_BUDGET = 5e3;
@@ -1089,7 +1396,7 @@ function findSection(p, want) {
1089
1396
  }
1090
1397
  var keyList = (p, counts = false) => [...p.sections.map((s) => counts && s.rows.length ? `${s.key} (${s.rows.length})` : s.key), "frontmatter"].join(", ");
1091
1398
  var titleOf = (p, path) => p.preamble.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() || path;
1092
- function clip(s, n, mark = "\u2026") {
1399
+ function clip2(s, n, mark = "\u2026") {
1093
1400
  if (s.length <= n) return s;
1094
1401
  const end = /[\uD800-\uDBFF]/.test(s[n - 2]) ? n - 2 : n - 1;
1095
1402
  return `${s.slice(0, end)}${mark}`;
@@ -1152,7 +1459,7 @@ function compactView(md, path, budget) {
1152
1459
  const more = `\u2026 (section: "${s.key}")`;
1153
1460
  if (!s.rows.length) {
1154
1461
  const text2 = s.head.filter((l) => !blank(l)).join("\n");
1155
- if (text2) out.push(clip(text2, PROSE_CLIP, more));
1462
+ if (text2) out.push(clip2(text2, PROSE_CLIP, more));
1156
1463
  } else {
1157
1464
  const lead = s.head.find((l) => !blank(l) && !l.startsWith("|") && !l.startsWith("### "));
1158
1465
  if (lead && lead.length <= HEAD_LINE_MAX) out.push(lead);
@@ -1165,12 +1472,12 @@ function compactView(md, path, budget) {
1165
1472
  const t = r.table ? r.table.join("\n") : null;
1166
1473
  if (r.table && t !== table2) out.push(...r.table);
1167
1474
  table2 = t;
1168
- out.push(clip(r.lines[0], ROW_CLIP));
1475
+ out.push(clip2(r.lines[0], ROW_CLIP));
1169
1476
  }
1170
1477
  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
1478
  else {
1172
1479
  const text2 = s.tail.filter((l) => !blank(l)).join("\n");
1173
- if (text2) out.push(clip(text2, PROSE_CLIP, more));
1480
+ if (text2) out.push(clip2(text2, PROSE_CLIP, more));
1174
1481
  }
1175
1482
  }
1176
1483
  out.push("");
@@ -1264,7 +1571,7 @@ ${pageView(md, clean, {}, budget, site)}`;
1264
1571
  }
1265
1572
 
1266
1573
  // src/server.ts
1267
- var VERSION = "0.5.0";
1574
+ var VERSION = "0.7.1";
1268
1575
  var SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
1269
1576
  var LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
1270
1577
  var BUDGET = budgetOf(process.env.BC_OBSERVATORY_MAX_CHARS);
@@ -1293,6 +1600,7 @@ async function pageMarkdown(path) {
1293
1600
  var pages = [];
1294
1601
  var index = null;
1295
1602
  var loadedAt = 0;
1603
+ var symbolsMajor = null;
1296
1604
  async function shard(file, sha) {
1297
1605
  const cached = join(cacheDir, `${sha}.json`);
1298
1606
  if (!LOCAL && existsSync(cached)) return readFileSync(cached, "utf8");
@@ -1320,6 +1628,7 @@ async function loadIndex() {
1320
1628
  } catch {
1321
1629
  sm = null;
1322
1630
  }
1631
+ symbolsMajor = sm?.major ?? null;
1323
1632
  if (sm) {
1324
1633
  const byPath = new Map(idx.records.map((r) => [r.id, r]));
1325
1634
  for (const [k, e] of Object.entries(sm.kinds)) {
@@ -1332,7 +1641,37 @@ async function loadIndex() {
1332
1641
  }
1333
1642
  index = idx;
1334
1643
  loadedAt = Date.now();
1644
+ countryDiffs.clear();
1645
+ }
1646
+ var countryDiffs = /* @__PURE__ */ new Map();
1647
+ function diffMajor() {
1648
+ if (symbolsMajor) return symbolsMajor;
1649
+ let best2 = 0;
1650
+ for (const r of index?.records ?? []) for (const part of (r.major ?? "").split(/[\s,]+/)) {
1651
+ const n = Number(part.split("-").pop());
1652
+ if (n > best2) best2 = n;
1653
+ }
1654
+ return best2 ? String(best2) : null;
1335
1655
  }
1656
+ function replacedObjects(major, cc) {
1657
+ const key = `${major}-${cc}`, hit = countryDiffs.get(key);
1658
+ if (hit && Date.now() - hit.at < DAY) return Promise.resolve(hit.objects);
1659
+ const loading = (async () => {
1660
+ try {
1661
+ const diff = JSON.parse(await dataFile(`code/diffs/country/${key}.json`));
1662
+ return new Map(diff.objects.filter((o) => o.change === "replaced").map((o) => [o.key, o]));
1663
+ } catch {
1664
+ return null;
1665
+ }
1666
+ })();
1667
+ countryDiffs.set(key, { at: Date.now(), objects: loading });
1668
+ void loading.then((o) => {
1669
+ const e = countryDiffs.get(key);
1670
+ if (e) e.objects = o;
1671
+ });
1672
+ return loading;
1673
+ }
1674
+ var replacedBy = async (major, cc, key) => (await replacedObjects(major, cc))?.get(key) ?? null;
1336
1675
  var url = (path) => `${SITE}${path}/`;
1337
1676
  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
1677
  in: ${r.path_label}` : ""}${r.stats ? `
@@ -1396,7 +1735,7 @@ function fuse(lists, k = 60) {
1396
1735
  }
1397
1736
  function keepOf(a) {
1398
1737
  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);
1738
+ 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
1739
  const system = a.system ? systemOf(a.system) ?? lc(a.system) : void 0;
1401
1740
  const objectType = a.object_type ? typeOfWord(a.object_type.replace(/\s+/g, "")) ?? lc(a.object_type) : void 0;
1402
1741
  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 +1746,11 @@ async function toolSearch(a) {
1407
1746
  const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
1408
1747
  const q = parseQuery(a.query, { countries: countriesOf(index) });
1409
1748
  const hits = search(index, q, { filter: keep2 });
1410
- const byId = new Map(hits.map((h) => [h.r.id, h.r]));
1749
+ const kind = q.kind ?? KINDS[a.kind?.trim().toLowerCase() ?? ""] ?? null;
1750
+ const uid = (r) => r.kind !== "page" && r.country ? `${r.id} ${r.country}` : r.id;
1751
+ const byId = new Map(hits.map((h) => [uid(h.r), h.r]));
1411
1752
  let semantic = [], note = "";
1412
- if (mode !== "keyword") {
1753
+ if (mode !== "keyword" && !kind) {
1413
1754
  const m = await loadModel();
1414
1755
  if (m) {
1415
1756
  const v = embed(m, a.query), data = pageVectors(m);
@@ -1426,27 +1767,36 @@ async function toolSearch(a) {
1426
1767
  }
1427
1768
  } else if (mode === "semantic") note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
1428
1769
  }
1429
- const keyword = hits.map((h) => h.r.id);
1770
+ const keyword = hits.map((h) => uid(h.r));
1430
1771
  let order, exactBand = 0;
1431
1772
  if (mode === "semantic") order = semantic.length ? semantic : keyword.slice(0, POOL);
1432
1773
  else if (mode === "keyword" || !semantic.length) order = keyword;
1433
1774
  else {
1434
- const exact = hits.filter((h) => h.band === "exact").map((h) => h.r.id), pinned = new Set(exact);
1775
+ const exact = hits.filter((h) => h.band === "exact").map((h) => uid(h.r)), pinned = new Set(exact);
1435
1776
  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))])];
1777
+ order = [...exact, ...fuse([hits.filter((h) => h.band !== "exact").slice(0, POOL).map((h) => uid(h.r)), semantic.filter((p) => !pinned.has(p))])];
1437
1778
  }
1438
1779
  if (mode !== "semantic" && mode !== "keyword" && !semantic.length) exactBand = hits.filter((h) => h.band === "exact").length;
1439
1780
  const inKeyword = new Set(keyword.slice(0, Math.max(POOL, limit))), inMeaning = new Set(semantic);
1440
1781
  const tag = (id) => inKeyword.has(id) && inMeaning.has(id) ? " [both]" : inMeaning.has(id) ? " [meaning]" : " [keyword]";
1441
1782
  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));
1783
+ const filters = ["type", "tier", "system", "country", "app", "object_type"].filter((k) => a[k]?.trim()).map((k) => `${k}=${a[k].trim()}`).join(", ");
1784
+ const unfiltered = kind && !hits.length && filters ? search(index, q, { filter: (r) => r.kind === kind }) : void 0;
1785
+ 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
1786
  const hintText = hs.length ? `
1444
1787
  ${hs.map((h) => `Hint: ${h.text}${h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : ""}`).join("\n")}` : "";
1788
+ if (!shown.length && kind && /^No \w+ named /.test(hs[0]?.text ?? "")) {
1789
+ const [first, ...more] = hs, via = (h) => h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : "";
1790
+ return `${first.text}${via(first)}${note}${more.map((h) => `
1791
+ Hint: ${h.text}${via(h)}`).join("")}`;
1792
+ }
1445
1793
  if (!shown.length) return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.${note}${hintText}`;
1446
1794
  const how = semantic.length && keyword.length && mode === "hybrid" ? "keyword + meaning" : mode === "semantic" && semantic.length ? "meaning" : "keyword";
1447
- 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}`;
1795
+ const learnNote = shown.some((r) => r.type === "learn") ? "; a Learn article's url reads with the Learn MCP's microsoft_docs_fetch (the plugin connects it), else open it on learn.microsoft.com; cat(hub) reads its hub" : "";
1796
+ 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}):
1797
+ ${shown.map((r) => hitLine(r, tag(uid(r)))).join("\n")}${hintText}`;
1798
+ return `${shown.length} results, ${how}${note}${how === "keyword + meaning" ? ` (exact band: ${exactBand})` : ""} (read one with cat(path)${learnNote}):
1799
+ ${shown.map((r) => hitLine(r, tag(uid(r)))).join("\n")}${hintText}`;
1450
1800
  }
1451
1801
  async function toolLs(a) {
1452
1802
  await loadIndex();
@@ -1468,7 +1818,27 @@ async function toolLs(a) {
1468
1818
  async function toolCat(a) {
1469
1819
  const [base, anchor] = a.path.split(/(?=#)/);
1470
1820
  const clean = `${base.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "")}${anchor ?? ""}`;
1471
- return pageView(await pageMarkdown(clean.replace(/#.*$/, "")), clean, a, BUDGET, SITE);
1821
+ const path = clean.replace(/#.*$/, ""), md = await pageMarkdown(path);
1822
+ if (anchor && anchorView(md, path, anchor, SITE) === null) {
1823
+ const added = await layerMember(path, anchor);
1824
+ if (added) return added;
1825
+ }
1826
+ return pageView(md, clean, a, BUDGET, SITE);
1827
+ }
1828
+ async function layerMember(path, anchor) {
1829
+ const want = parseAnchor(anchor), major = diffMajor();
1830
+ if (!want || !major) return null;
1831
+ await loadIndex();
1832
+ const id = `${path}${anchor}`;
1833
+ 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();
1834
+ const owner = index.records.find((r) => r.kind === "page" && r.id === path);
1835
+ const out = [];
1836
+ for (const cc of layers) {
1837
+ const o = owner?.objectId != null ? await replacedBy(major, cc, `${owner.objectType}/${owner.objectId}`) : null;
1838
+ const text2 = o && memberAnswer(o, want, cc, { title: owner?.title, site: SITE, path });
1839
+ if (text2) out.push(text2);
1840
+ }
1841
+ return out.length ? out.join("\n") : null;
1472
1842
  }
1473
1843
  async function toolGetObject(a) {
1474
1844
  await loadIndex();
@@ -1503,12 +1873,24 @@ async function toolGetObject(a) {
1503
1873
  const pick = country ? inCountry.length ? inCountry : w1 : w1.length ? w1 : found;
1504
1874
  if (pick.length === 1) {
1505
1875
  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.)
1876
+ const major = diffMajor(), key = `${type}/${pick[0].objectId ?? pick[0].name.toLowerCase()}`;
1877
+ let note = "";
1878
+ if (country && !inCountry.length) {
1879
+ const replaced = major ? await replacedBy(major, country, key) : null;
1880
+ note = replaced && major ? `${countryBlock(replaced, country, major, { title: pick[0].title })}
1507
1881
 
1508
- ` : "";
1509
- const also = !country && twins.length ? `
1882
+ ` : `(${country.toUpperCase()} has no ${type} of its own here; the W1 ${type} applies.)
1883
+
1884
+ `;
1885
+ }
1886
+ let also = !country && twins.length ? `
1510
1887
 
1511
1888
  (Also in ${twins.join(", ")}: get_object("${type}", "${pick[0].objectId ?? pick[0].name}", country).)` : "";
1889
+ if (!country && !pick[0].country && major && !a.section) {
1890
+ const layers = (await Promise.all(countriesOf(index).map(async (cc2) => ({ cc: cc2, object: await replacedBy(major, cc2, key) })))).filter((l) => !!l.object);
1891
+ const line2 = replacedInLine(layers);
1892
+ if (line2) also += `${also ? "\n" : "\n\n"}${line2}`;
1893
+ }
1512
1894
  return `${note}${pageView(await pageMarkdown(pick[0].id), pick[0].id, a, BUDGET, SITE)}${a.section ? "" : also}`;
1513
1895
  }
1514
1896
  if (pick.length > 1) return `${pick.length} ${type} objects match "${a.idOrName}"; read one with cat(path):
@@ -1566,13 +1948,13 @@ ${a.message}
1566
1948
  var VIEW = { section: z.string().optional(), offset: z.number().int().min(0).max(1e5).optional(), limit: z.number().int().min(1).max(5e3).optional() };
1567
1949
  function createServer() {
1568
1950
  const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
1569
- instructions: "BC Observatory: an agent-first knowledge base of Microsoft Dynamics 365 Business Central (Learn hubs, AL objects from the code for BC28-30, localizations, roadmap features, videos, community posts, code changes: merged pull requests of Microsoft's Business Central repositories, BCApps joined to the AL objects they changed, under changes/, and AL Language extension releases (the marketplace changelog, under releases/)). Every page carries a trust tier (official = Microsoft, community = everyone else) and a review state: say which tier a claim comes from. Never invent AL object ids or version numbers: look them up with get_object. Start with search(), read pages with cat(path), browse with ls(path). search() also finds Microsoft Learn articles by title, summary and the identifiers they document (type learn): those are not pages here, open their url on learn.microsoft.com."
1951
+ instructions: "BC Observatory: an agent-first knowledge base of Microsoft Dynamics 365 Business Central (Learn hubs, AL objects from the code for BC28-30, localizations, roadmap features, videos, community posts, code changes: merged pull requests of Microsoft's Business Central repositories, BCApps joined to the AL objects they changed, under changes/, and AL Language extension releases (the marketplace changelog, under releases/)). Every page carries a trust tier (official = Microsoft, community = everyone else) and a review state: say which tier a claim comes from. Never invent AL object ids or version numbers: look them up with get_object. Start with search(), read pages with cat(path), browse with ls(path). search() also finds Microsoft Learn articles by title, summary and the identifiers they document (type learn): those are not pages here: read their url with the Learn MCP's microsoft_docs_fetch (the plugin connects it), else open it on learn.microsoft.com."
1570
1952
  });
1571
1953
  server.registerTool(
1572
1954
  "search",
1573
1955
  {
1574
1956
  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).",
1957
+ 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
1958
  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
1959
  },
1578
1960
  async (a) => text(await toolSearch(a))
@@ -1589,7 +1971,7 @@ function createServer() {
1589
1971
  );
1590
1972
  server.registerTool("get_object", {
1591
1973
  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.`,
1974
+ 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
1975
  inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional(), ...VIEW }
1594
1976
  }, async (a) => text(await toolGetObject(a)));
1595
1977
  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.1",
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": {