bc-observatory 0.4.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 +485 -74
  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,
@@ -393,13 +469,22 @@ function normOf(r) {
393
469
  const symbol = r.kind !== "page";
394
470
  const main = /* @__PURE__ */ new Set([...indexTokens(r.name, symbol), ...indexTokens(r.title, symbol), ...r.caption ? indexTokens(r.caption) : []]);
395
471
  const own = r.type === "topic" ? new Set(tokenize(r.title)) : null;
396
- const tags = (r.tags ?? []).flatMap((t) => indexTokens(t)).filter((t) => !own?.has(t));
397
- const eqs = r.type === "object" || symbol ? [tokenize(r.name), ...r.caption ? [tokenize(r.caption)] : []] : [tokenize(r.title)];
398
- const n = { main: [...main], tags: [...new Set(tags)], text: symbol ? null : indexTokens(r.text ?? ""), eqs };
472
+ const learn = r.type === "learn";
473
+ const tags = (r.tags ?? []).flatMap((t) => indexTokens(t, learn)).filter((t) => !own?.has(t));
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) : []];
475
+ const n = { main: [...main], tags: learn ? tags : [...new Set(tags)], text: symbol ? null : indexTokens(r.text ?? ""), eqs };
399
476
  NORM.set(r, n);
400
477
  return n;
401
478
  }
402
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
+ }
403
488
  function prepare(records, prev) {
404
489
  const index2 = prev ?? { records: [], postings: /* @__PURE__ */ new Map(), vocab: /* @__PURE__ */ new Map(), byType: /* @__PURE__ */ new Map(), maxInbound: /* @__PURE__ */ new Map() };
405
490
  let st = STATE.get(index2);
@@ -521,7 +606,7 @@ function matchersOf(index2, q) {
521
606
  }
522
607
  return m;
523
608
  }
524
- function best(m, toks) {
609
+ function best(m, toks, seq = () => toks) {
525
610
  let b = null;
526
611
  for (const t of toks) {
527
612
  const f = m.words.get(t);
@@ -529,7 +614,8 @@ function best(m, toks) {
529
614
  }
530
615
  for (const p of m.phrases) {
531
616
  if (b && b.f >= p.f) continue;
532
- const f = phraseForm(p.words, toks);
617
+ if (!p.words.every((w) => holds(toks, w))) continue;
618
+ const f = phraseForm(p.words, seq());
533
619
  if (f) {
534
620
  const v = p.f * f;
535
621
  if (!b || v > b.f) b = { f: v, how: p.how, ...p.via ? { via: p.via } : {} };
@@ -537,6 +623,21 @@ function best(m, toks) {
537
623
  }
538
624
  return b;
539
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
+ }
540
641
  function phraseForm(words, toks) {
541
642
  let bestF = 0;
542
643
  for (let i = 0; i + words.length <= toks.length; i++) {
@@ -550,7 +651,7 @@ function phraseForm(words, toks) {
550
651
  }
551
652
  return bestF;
552
653
  }
553
- function nameMatch(eq, ms, plural) {
654
+ function nameMatch(eq, ms, plural2) {
554
655
  let pos = 0, f = 1, lastPrefix = false;
555
656
  for (const m of ms) {
556
657
  const ws = m.term.phrase ? m.term.text.split(" ") : null;
@@ -558,7 +659,7 @@ function nameMatch(eq, ms, plural) {
558
659
  for (const w of ws) {
559
660
  const t2 = eq[pos++];
560
661
  if (t2 === void 0) return { eq: 0, starts: 0 };
561
- 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);
562
663
  }
563
664
  if (!f) return { eq: 0, starts: 0 };
564
665
  lastPrefix = false;
@@ -575,10 +676,10 @@ function nameMatch(eq, ms, plural) {
575
676
  lastPrefix = p.how === "prefix";
576
677
  continue;
577
678
  }
578
- f = Math.min(f, form.how === "stem" && plural ? 1 : form.f);
679
+ f = Math.min(f, form.how === "stem" && plural2 ? 1 : form.f);
579
680
  lastPrefix = form.how === "prefix";
580
681
  }
581
- 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 };
582
683
  return { eq: 0, starts: pos <= eq.length ? f : 0 };
583
684
  }
584
685
  var NARRATIVE = { reviewed: 2, unreviewed: 0, none: -2, derived: -2 };
@@ -599,7 +700,7 @@ function scoreRecord(index2, r, q) {
599
700
  if (q.ref.country) return { s: country === q.ref.country ? 1e3 : country === null ? 900 : 850, why };
600
701
  return { s: country === null ? 1e3 : 900, why };
601
702
  }
602
- const prior = object ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
703
+ const prior = objectish ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
603
704
  const lift = (base) => base + (object ? LAYER[r.layer] : symbol ? 0 : 1) + (objectish ? 2 * r.importance : 0) + prior;
604
705
  if (q.number !== null) {
605
706
  if (!object || r.objectId !== q.number) return none;
@@ -615,10 +716,10 @@ function scoreRecord(index2, r, q) {
615
716
  if (object && country && q.countries.includes(country)) return { s: 3 + 2 * r.importance + prior, why };
616
717
  return none;
617
718
  }
618
- const n = normOf(r), ms = matchersOf(index2, q);
619
- 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;
620
721
  for (const m of ms) {
621
- 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);
622
723
  if (!main && !tags && !text2) return none;
623
724
  if (main || tags) named = true;
624
725
  if (symbol && !gate && n.eqs[0].some((t) => {
@@ -627,21 +728,25 @@ function scoreRecord(index2, r, q) {
627
728
  })) gate = true;
628
729
  if (symbol && /^on[a-z]/.test(m.term.text)) gate = true;
629
730
  s += 3 * (main?.f ?? 0) + 2 * (tags?.f ?? 0) + (text2?.f ?? 0);
731
+ textPts += text2?.f ?? 0;
630
732
  const via = [main, tags, text2].find((x) => x?.via && (x.how === "syn" || x.how === "fuzzy"))?.via;
631
733
  if (via && !why.includes(`matched "${via}"`)) why.push(`matched "${via}"`);
632
734
  }
633
735
  if (!gate) return none;
736
+ const learn = r.type === "learn";
634
737
  let eq = 0, starts = 0, byName = false;
635
738
  n.eqs.forEach((e, k) => {
636
739
  const x = nameMatch(e, ms, objectish);
637
740
  if (x.eq > eq) {
638
741
  eq = x.eq;
639
- byName = k === 0;
742
+ byName = k === 0 || learn;
640
743
  }
641
- starts = Math.max(starts, x.starts);
744
+ if (!learn || k === 0) starts = Math.max(starts, x.starts);
642
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;
643
748
  if (eq) {
644
- s += 10 * eq;
749
+ s += 10 * eq - (owned ? textPts : 0);
645
750
  why.push(!byName ? "caption" : eq >= 0.75 ? "name" : "name~");
646
751
  } else if (starts) {
647
752
  s += 3 * starts;
@@ -651,8 +756,7 @@ function scoreRecord(index2, r, q) {
651
756
  if ((r.type === "topic" || r.type === "app") && !symbol) s += NARRATIVE[r.narrative ?? (r.type === "app" ? "none" : "unreviewed")] ?? 0;
652
757
  s += countryPoints(q, r, country);
653
758
  if (q.types.length && objectish) s += 3;
654
- s = lift(s);
655
- 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);
656
760
  if (objectish && !lifted) {
657
761
  s *= 0.6;
658
762
  why.push("demoted");
@@ -694,11 +798,11 @@ function search(index2, q, opts = {}) {
694
798
  return opts.limit ? hits.slice(0, opts.limit) : hits;
695
799
  }
696
800
  function symbolLine(r) {
697
- const of = r.owner ? ` of ${r.owner.title}` : "", x = r.extra ?? {};
698
- if (r.kind === "field") return `field ${x.id}${of} \xB7 ${x.type}`;
699
- if (r.kind === "event") return `event${of} \xB7 ${x.kind} \xB7 ${x.subscribers} ${x.subscribers === 1 ? "subscriber" : "subscribers"}${x.obsolete ? ` \xB7 obsolete ${x.obsolete}` : ""}`;
700
- 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}` : ""}`;
701
- 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}"` : ""}`;
702
806
  return r.title;
703
807
  }
704
808
  function objectsAt(index2, type, id) {
@@ -719,7 +823,8 @@ function typoWords(index2, q) {
719
823
  function namedBy(index2, r, q) {
720
824
  if (!STATE.get(index2)) return 0;
721
825
  const n = normOf(r);
722
- 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);
723
828
  }
724
829
  function symbolRecords(kind, rows, ownerOf) {
725
830
  const out = [];
@@ -735,7 +840,7 @@ function countriesOf(index2) {
735
840
  }
736
841
 
737
842
  // ../search/src/hints.ts
738
- function hints(index2, q, hits) {
843
+ function hints(index2, q, hits, opts = {}) {
739
844
  const out = [];
740
845
  const unknown = q.warnings.map((w) => /^unknown abbreviation (\w+)$/.exec(w)?.[1]).find(Boolean);
741
846
  const digits = /(\d+)\s*$/.exec(q.raw)?.[1];
@@ -767,21 +872,109 @@ function hints(index2, q, hits) {
767
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) });
768
873
  }
769
874
  if (q.question) {
770
- let hub = null, best2 = 0;
771
- for (const i of index2.byType.get("topic") ?? []) {
772
- const r = index2.records[i], named = namedBy(index2, r, q);
773
- if (!named) continue;
774
- const s = 3 * named + Math.log2((r.members ?? 0) + 1) + (r.narrative === "reviewed" ? 2 : r.narrative === "none" ? -2 : 0);
775
- if (s > best2) {
776
- best2 = s;
777
- 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
+ }
778
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);
779
905
  }
780
- 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 } : {} });
781
906
  }
782
- void hits;
783
907
  return out;
784
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
+ }
958
+
959
+ // ../search/src/learn.ts
960
+ function learnRecords(doc) {
961
+ return doc.rows.map(([path, title, summary, date, h, system, ids]) => {
962
+ const hub = h >= 0 ? doc.hubs[h] : void 0;
963
+ const sys = system ?? hub?.[2] ?? null;
964
+ return {
965
+ path: /^https?:\/\//.test(path) ? path : `${doc.base}${path}`,
966
+ type: "learn",
967
+ title,
968
+ summary,
969
+ tier: "official",
970
+ ...sys ? { system: sys } : {},
971
+ ...date ? { date } : {},
972
+ ...ids?.length ? { tags: ids } : {},
973
+ ...hub?.[1] ? { path_label: hub[1] } : {},
974
+ ...hub?.[0] ? { hub: hub[0] } : {}
975
+ };
976
+ });
977
+ }
785
978
 
786
979
  // src/embed.ts
787
980
  var MAX_TOKENS = 512;
@@ -891,6 +1084,139 @@ function dot(a, b, bOffset = 0) {
891
1084
  return s;
892
1085
  }
893
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
+
894
1220
  // src/sections.ts
895
1221
  var DEFAULT_BUDGET = 3e4;
896
1222
  var MIN_BUDGET = 5e3;
@@ -1068,7 +1394,7 @@ function findSection(p, want) {
1068
1394
  }
1069
1395
  var keyList = (p, counts = false) => [...p.sections.map((s) => counts && s.rows.length ? `${s.key} (${s.rows.length})` : s.key), "frontmatter"].join(", ");
1070
1396
  var titleOf = (p, path) => p.preamble.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() || path;
1071
- function clip(s, n, mark = "\u2026") {
1397
+ function clip2(s, n, mark = "\u2026") {
1072
1398
  if (s.length <= n) return s;
1073
1399
  const end = /[\uD800-\uDBFF]/.test(s[n - 2]) ? n - 2 : n - 1;
1074
1400
  return `${s.slice(0, end)}${mark}`;
@@ -1131,7 +1457,7 @@ function compactView(md, path, budget) {
1131
1457
  const more = `\u2026 (section: "${s.key}")`;
1132
1458
  if (!s.rows.length) {
1133
1459
  const text2 = s.head.filter((l) => !blank(l)).join("\n");
1134
- if (text2) out.push(clip(text2, PROSE_CLIP, more));
1460
+ if (text2) out.push(clip2(text2, PROSE_CLIP, more));
1135
1461
  } else {
1136
1462
  const lead = s.head.find((l) => !blank(l) && !l.startsWith("|") && !l.startsWith("### "));
1137
1463
  if (lead && lead.length <= HEAD_LINE_MAX) out.push(lead);
@@ -1144,12 +1470,12 @@ function compactView(md, path, budget) {
1144
1470
  const t = r.table ? r.table.join("\n") : null;
1145
1471
  if (r.table && t !== table2) out.push(...r.table);
1146
1472
  table2 = t;
1147
- out.push(clip(r.lines[0], ROW_CLIP));
1473
+ out.push(clip2(r.lines[0], ROW_CLIP));
1148
1474
  }
1149
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})`);
1150
1476
  else {
1151
1477
  const text2 = s.tail.filter((l) => !blank(l)).join("\n");
1152
- if (text2) out.push(clip(text2, PROSE_CLIP, more));
1478
+ if (text2) out.push(clip2(text2, PROSE_CLIP, more));
1153
1479
  }
1154
1480
  }
1155
1481
  out.push("");
@@ -1243,7 +1569,7 @@ ${pageView(md, clean, {}, budget, site)}`;
1243
1569
  }
1244
1570
 
1245
1571
  // src/server.ts
1246
- var VERSION = "0.4.0";
1572
+ var VERSION = "0.7.0";
1247
1573
  var SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
1248
1574
  var LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
1249
1575
  var BUDGET = budgetOf(process.env.BC_OBSERVATORY_MAX_CHARS);
@@ -1272,6 +1598,7 @@ async function pageMarkdown(path) {
1272
1598
  var pages = [];
1273
1599
  var index = null;
1274
1600
  var loadedAt = 0;
1601
+ var symbolsMajor = null;
1275
1602
  async function shard(file, sha) {
1276
1603
  const cached = join(cacheDir, `${sha}.json`);
1277
1604
  if (!LOCAL && existsSync(cached)) return readFileSync(cached, "utf8");
@@ -1287,12 +1614,19 @@ async function loadIndex() {
1287
1614
  for (const s of manifest.shards) all.push(...JSON.parse(await shard(s.file, s.sha256)));
1288
1615
  pages = all;
1289
1616
  const idx = prepare(all.map(pageToRecord));
1617
+ if (manifest.learn) {
1618
+ try {
1619
+ prepare(learnRecords(JSON.parse(await shard(manifest.learn.file, manifest.learn.sha256))).map(pageToRecord), idx);
1620
+ } catch {
1621
+ }
1622
+ }
1290
1623
  let sm = null;
1291
1624
  try {
1292
1625
  sm = JSON.parse(await dataFile("index/symbols-manifest.json"));
1293
1626
  } catch {
1294
1627
  sm = null;
1295
1628
  }
1629
+ symbolsMajor = sm?.major ?? null;
1296
1630
  if (sm) {
1297
1631
  const byPath = new Map(idx.records.map((r) => [r.id, r]));
1298
1632
  for (const [k, e] of Object.entries(sm.kinds)) {
@@ -1305,7 +1639,37 @@ async function loadIndex() {
1305
1639
  }
1306
1640
  index = idx;
1307
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;
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;
1308
1671
  }
1672
+ var replacedBy = async (major, cc, key) => (await replacedObjects(major, cc))?.get(key) ?? null;
1309
1673
  var url = (path) => `${SITE}${path}/`;
1310
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 ? `
1311
1675
  in: ${r.path_label}` : ""}${r.stats ? `
@@ -1314,7 +1678,10 @@ var line = (r, tag = "") => `- ${r.title}${r.caption ? ` (captioned "${r.caption
1314
1678
  var text = (t) => ({ content: [{ type: "text", text: t }] });
1315
1679
  var symbolHit = (r, tag = "") => `- ${r.name} (${symbolLine(r).replace(/ · /g, ", ")}) path=${r.id}${tag}${r.text ? `
1316
1680
  ${r.text}` : ""}`;
1317
- var hitLine = (r, tag = "") => r.kind === "page" && r.page ? line(r.page, tag) : symbolHit(r, tag);
1681
+ var learnLine = (r, tag = "") => `- ${r.title} [learn, official${r.date ? `, ${r.date}` : ""}] url=${r.path}${tag}${r.path_label ? `
1682
+ in: ${r.path_label}${r.hub ? ` (hub: ${r.hub})` : ""}` : ""}
1683
+ ${r.summary}`;
1684
+ var hitLine = (r, tag = "") => r.kind === "page" && r.page ? r.type === "learn" ? learnLine(r.page, tag) : line(r.page, tag) : symbolHit(r, tag);
1318
1685
  var MODEL = {
1319
1686
  repo: "minishlab/potion-base-8M",
1320
1687
  revision: "bf8b056651a2c21b8d2565580b8569da283cab23",
@@ -1366,7 +1733,7 @@ function fuse(lists, k = 60) {
1366
1733
  }
1367
1734
  function keepOf(a) {
1368
1735
  const lc = (x) => x?.trim().toLowerCase() || void 0;
1369
- 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);
1370
1737
  const system = a.system ? systemOf(a.system) ?? lc(a.system) : void 0;
1371
1738
  const objectType = a.object_type ? typeOfWord(a.object_type.replace(/\s+/g, "")) ?? lc(a.object_type) : void 0;
1372
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);
@@ -1377,9 +1744,11 @@ async function toolSearch(a) {
1377
1744
  const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
1378
1745
  const q = parseQuery(a.query, { countries: countriesOf(index) });
1379
1746
  const hits = search(index, q, { filter: keep2 });
1380
- 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]));
1381
1750
  let semantic = [], note = "";
1382
- if (mode !== "keyword") {
1751
+ if (mode !== "keyword" && !kind) {
1383
1752
  const m = await loadModel();
1384
1753
  if (m) {
1385
1754
  const v = embed(m, a.query), data = pageVectors(m);
@@ -1396,26 +1765,36 @@ async function toolSearch(a) {
1396
1765
  }
1397
1766
  } else if (mode === "semantic") note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
1398
1767
  }
1399
- const keyword = hits.map((h) => h.r.id);
1768
+ const keyword = hits.map((h) => uid(h.r));
1400
1769
  let order, exactBand = 0;
1401
1770
  if (mode === "semantic") order = semantic.length ? semantic : keyword.slice(0, POOL);
1402
1771
  else if (mode === "keyword" || !semantic.length) order = keyword;
1403
1772
  else {
1404
- 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);
1405
1774
  exactBand = exact.length;
1406
- 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))])];
1407
1776
  }
1408
1777
  if (mode !== "semantic" && mode !== "keyword" && !semantic.length) exactBand = hits.filter((h) => h.band === "exact").length;
1409
1778
  const inKeyword = new Set(keyword.slice(0, Math.max(POOL, limit))), inMeaning = new Set(semantic);
1410
1779
  const tag = (id) => inKeyword.has(id) && inMeaning.has(id) ? " [both]" : inMeaning.has(id) ? " [meaning]" : " [keyword]";
1411
1780
  const shown = order.slice(0, limit).map((id) => byId.get(id)).filter(Boolean);
1412
- 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));
1413
1784
  const hintText = hs.length ? `
1414
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
+ }
1415
1791
  if (!shown.length) return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.${note}${hintText}`;
1416
1792
  const how = semantic.length && keyword.length && mode === "hybrid" ? "keyword + meaning" : mode === "semantic" && semantic.length ? "meaning" : "keyword";
1417
- return `${shown.length} results, ${how}${note}${how === "keyword + meaning" || mode === "hybrid" ? ` (exact band: ${exactBand})` : ""} (read one with cat(path)):
1418
- ${shown.map((r) => hitLine(r, tag(r.id))).join("\n")}${hintText}`;
1793
+ const learnNote = shown.some((r) => r.type === "learn") ? "; a Learn article's url opens on learn.microsoft.com, cat(hub) reads its hub" : "";
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}`;
1419
1798
  }
1420
1799
  async function toolLs(a) {
1421
1800
  await loadIndex();
@@ -1437,7 +1816,27 @@ async function toolLs(a) {
1437
1816
  async function toolCat(a) {
1438
1817
  const [base, anchor] = a.path.split(/(?=#)/);
1439
1818
  const clean = `${base.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "")}${anchor ?? ""}`;
1440
- 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;
1441
1840
  }
1442
1841
  async function toolGetObject(a) {
1443
1842
  await loadIndex();
@@ -1472,12 +1871,24 @@ async function toolGetObject(a) {
1472
1871
  const pick = country ? inCountry.length ? inCountry : w1 : w1.length ? w1 : found;
1473
1872
  if (pick.length === 1) {
1474
1873
  const twins = found.filter((r) => r !== pick[0] && r.country).map((r) => r.country.toUpperCase());
1475
- 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 })}
1476
1879
 
1477
- ` : "";
1478
- 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 ? `
1479
1885
 
1480
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
+ }
1481
1892
  return `${note}${pageView(await pageMarkdown(pick[0].id), pick[0].id, a, BUDGET, SITE)}${a.section ? "" : also}`;
1482
1893
  }
1483
1894
  if (pick.length > 1) return `${pick.length} ${type} objects match "${a.idOrName}"; read one with cat(path):
@@ -1535,13 +1946,13 @@ ${a.message}
1535
1946
  var VIEW = { section: z.string().optional(), offset: z.number().int().min(0).max(1e5).optional(), limit: z.number().int().min(1).max(5e3).optional() };
1536
1947
  function createServer() {
1537
1948
  const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
1538
- 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)."
1949
+ 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."
1539
1950
  });
1540
1951
  server.registerTool(
1541
1952
  "search",
1542
1953
  {
1543
1954
  title: "Search the knowledge base",
1544
- 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. 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, 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).",
1545
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() }
1546
1957
  },
1547
1958
  async (a) => text(await toolSearch(a))
@@ -1558,7 +1969,7 @@ function createServer() {
1558
1969
  );
1559
1970
  server.registerTool("get_object", {
1560
1971
  title: "Look up an AL object",
1561
- 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.`,
1562
1973
  inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional(), ...VIEW }
1563
1974
  }, async (a) => text(await toolGetObject(a)));
1564
1975
  server.registerTool("diff_object", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bc-observatory",
3
- "version": "0.4.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": {