bc-observatory 0.3.0 → 0.5.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 +8 -0
  2. package/dist/server.js +403 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,4 +38,12 @@ model, search is keyword-only and says so.
38
38
  `get_object` takes an id, a reference (`t36`, `36-be`), a name or a caption, and a `country`; `whats_new` lists nothing
39
39
  dated after today unless `include_future: true`.
40
40
 
41
+ `cat`, `get_object` and `localization` return a page as it is when it fits 30,000 characters. A longer page
42
+ (Codeunit 80 "Sales-Post" is 177,746) comes back as a compact view: the frontmatter with long lists cut, the title
43
+ block, the section keys with their row counts and the first 5 rows of each section. `section` reads one section
44
+ (`fields`, `events`, `procedures`, `calls`, ..., or `frontmatter`), `offset` and `limit` page through it by rows, and
45
+ no answer exceeds 30,000 characters. A symbol's path from `search` (`objects/codeunit/80#event-OnAfterPostSalesDoc`)
46
+ returns just that member. `BC_OBSERVATORY_MAX_CHARS` changes the limit (`0` returns every page whole, as before
47
+ 0.4.0).
48
+
41
49
  MIT. Content: see the repository's CONTENT-NOTICE.md (Microsoft content attributed; community content derived only).
package/dist/server.js CHANGED
@@ -393,9 +393,10 @@ function normOf(r) {
393
393
  const symbol = r.kind !== "page";
394
394
  const main = /* @__PURE__ */ new Set([...indexTokens(r.name, symbol), ...indexTokens(r.title, symbol), ...r.caption ? indexTokens(r.caption) : []]);
395
395
  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));
396
+ const learn = r.type === "learn";
397
+ const tags = (r.tags ?? []).flatMap((t) => indexTokens(t, learn)).filter((t) => !own?.has(t));
397
398
  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 };
399
+ const n = { main: [...main], tags: learn ? tags : [...new Set(tags)], text: symbol ? null : indexTokens(r.text ?? ""), eqs };
399
400
  NORM.set(r, n);
400
401
  return n;
401
402
  }
@@ -783,6 +784,26 @@ function hints(index2, q, hits) {
783
784
  return out;
784
785
  }
785
786
 
787
+ // ../search/src/learn.ts
788
+ function learnRecords(doc) {
789
+ return doc.rows.map(([path, title, summary, date, h, system, ids]) => {
790
+ const hub = h >= 0 ? doc.hubs[h] : void 0;
791
+ const sys = system ?? hub?.[2] ?? null;
792
+ return {
793
+ path: /^https?:\/\//.test(path) ? path : `${doc.base}${path}`,
794
+ type: "learn",
795
+ title,
796
+ summary,
797
+ tier: "official",
798
+ ...sys ? { system: sys } : {},
799
+ ...date ? { date } : {},
800
+ ...ids?.length ? { tags: ids } : {},
801
+ ...hub?.[1] ? { path_label: hub[1] } : {},
802
+ ...hub?.[0] ? { hub: hub[0] } : {}
803
+ };
804
+ });
805
+ }
806
+
786
807
  // src/embed.ts
787
808
  var MAX_TOKENS = 512;
788
809
  function parseModel(tokenizerJson, safetensors, config = {}) {
@@ -891,10 +912,363 @@ function dot(a, b, bOffset = 0) {
891
912
  return s;
892
913
  }
893
914
 
915
+ // src/sections.ts
916
+ var DEFAULT_BUDGET = 3e4;
917
+ var MIN_BUDGET = 5e3;
918
+ var PREVIEW_ROWS = 5;
919
+ var ROW_CLIP = 200;
920
+ var PROSE_CLIP = 800;
921
+ var HEAD_LINE_MAX = 120;
922
+ var FM_LIST_CHARS = 600;
923
+ var FM_LIST_MIN = 3;
924
+ var RESERVE = 300;
925
+ var OBJECT_KEYS = {
926
+ "Properties": "properties",
927
+ "Fields": "fields",
928
+ "Fields on this page": "controls",
929
+ "Actions": "actions",
930
+ "Keys": "keys",
931
+ "Values": "values",
932
+ "Events published": "events",
933
+ "Trigger event subscribers": "trigger_subscribers",
934
+ "Event subscriptions": "subscriptions",
935
+ "Procedures": "procedures",
936
+ "Triggers": "triggers",
937
+ "Relations": "relations",
938
+ "Referenced by": "referenced_by",
939
+ "Pages and codeunits on this table": "pages",
940
+ "Extended by": "extended_by",
941
+ "Calls": "calls",
942
+ "Called by": "called_by",
943
+ "Implements": "implements",
944
+ "Implemented by": "implemented_by",
945
+ "Recent changes": "changes",
946
+ "Across versions": "versions",
947
+ "Countries that replace it": "countries",
948
+ "Documented on Microsoft Learn": "learn",
949
+ "Deprecations": "deprecations",
950
+ "Ask your agent": "ask"
951
+ };
952
+ var snake = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
953
+ var blank = (l) => l.trim() === "";
954
+ var fmt = (n) => n.toLocaleString("en-US");
955
+ var FENCE = /^\s*```/;
956
+ var SEPARATOR = /^\|\s*:?-/;
957
+ var CAP_NOTE = /^- and \d+ more/;
958
+ function budgetOf(env) {
959
+ const n = env === void 0 || env.trim() === "" ? NaN : Number(env);
960
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_BUDGET;
961
+ if (n === 0) return Infinity;
962
+ return Math.max(MIN_BUDGET, Math.floor(n));
963
+ }
964
+ function sectionKey(heading) {
965
+ return OBJECT_KEYS[heading] ?? snake(heading);
966
+ }
967
+ function splitSection(lines) {
968
+ const head = [], rows = [];
969
+ let pending = [];
970
+ let table2 = null, group = null;
971
+ let inTable = false, afterRow = false, fence = null;
972
+ const other = (line2, kind = "text") => {
973
+ if (rows.length) pending.push({ line: line2, kind });
974
+ else head.push(line2);
975
+ };
976
+ const addRow = (first) => {
977
+ if (rows.length) {
978
+ const prev = rows[rows.length - 1];
979
+ for (const p of pending) {
980
+ if (p.kind === "group" && p.line === group) continue;
981
+ if (p.kind === "table" && table2 && (p.line === table2[0] || p.line === table2[1])) continue;
982
+ if (!blank(p.line)) prev.lines.push(p.line);
983
+ }
984
+ }
985
+ pending = [];
986
+ rows.push({ lines: [first], table: table2, group });
987
+ afterRow = true;
988
+ };
989
+ for (let i = 0; i < lines.length; i++) {
990
+ const l = lines[i];
991
+ if (fence) {
992
+ if (fence === "row") rows[rows.length - 1].lines.push(l);
993
+ else other(l);
994
+ if (FENCE.test(l)) fence = null;
995
+ continue;
996
+ }
997
+ if (FENCE.test(l)) {
998
+ if (afterRow && /^ {2,}/.test(l)) {
999
+ rows[rows.length - 1].lines.push(l);
1000
+ fence = "row";
1001
+ } else {
1002
+ other(l);
1003
+ fence = "text";
1004
+ afterRow = false;
1005
+ inTable = false;
1006
+ table2 = null;
1007
+ }
1008
+ continue;
1009
+ }
1010
+ if (afterRow && /^ {2,}\S/.test(l)) {
1011
+ rows[rows.length - 1].lines.push(l);
1012
+ continue;
1013
+ }
1014
+ if (l.startsWith("|")) {
1015
+ if (!inTable && i + 1 < lines.length && SEPARATOR.test(lines[i + 1])) {
1016
+ table2 = [l, lines[i + 1]];
1017
+ inTable = true;
1018
+ afterRow = false;
1019
+ other(l, "table");
1020
+ other(lines[++i], "table");
1021
+ continue;
1022
+ }
1023
+ if (inTable) {
1024
+ addRow(l);
1025
+ continue;
1026
+ }
1027
+ } else if (inTable) {
1028
+ inTable = false;
1029
+ table2 = null;
1030
+ }
1031
+ if (l.startsWith("- ") && !CAP_NOTE.test(l)) {
1032
+ addRow(l);
1033
+ continue;
1034
+ }
1035
+ afterRow = false;
1036
+ if (l.startsWith("### ")) {
1037
+ group = l;
1038
+ other(l, "group");
1039
+ continue;
1040
+ }
1041
+ other(l);
1042
+ }
1043
+ const tail = pending.map((p) => p.line);
1044
+ return { head, rows, tail };
1045
+ }
1046
+ var memo = null;
1047
+ function parsePage(md) {
1048
+ if (memo && memo.md === md) return memo.page;
1049
+ const all = md.split("\n");
1050
+ let i = 0, frontmatter = "";
1051
+ if (all[0] === "---") {
1052
+ const end = all.indexOf("---", 1);
1053
+ if (end > 0) {
1054
+ frontmatter = all.slice(1, end).join("\n");
1055
+ i = end + 1;
1056
+ }
1057
+ }
1058
+ const pre = [], chunks = [];
1059
+ let fence = false;
1060
+ for (; i < all.length; i++) {
1061
+ const l = all[i];
1062
+ if (!fence && l.startsWith("## ")) {
1063
+ chunks.push({ heading: l.slice(3).trim(), lines: [] });
1064
+ continue;
1065
+ }
1066
+ if (FENCE.test(l)) fence = !fence;
1067
+ (chunks.length ? chunks[chunks.length - 1].lines : pre).push(l);
1068
+ }
1069
+ const seen = /* @__PURE__ */ new Map();
1070
+ const sections = chunks.map(({ heading, lines }) => {
1071
+ while (lines.length && blank(lines[lines.length - 1])) lines.pop();
1072
+ const base = sectionKey(heading), n = (seen.get(base) ?? 0) + 1;
1073
+ seen.set(base, n);
1074
+ return { heading, key: n === 1 ? base : `${base}_${n}`, lines, ...splitSection(lines) };
1075
+ });
1076
+ const preamble = pre.join("\n").replace(/^\s*\n/, "").replace(/\s+$/, "");
1077
+ const page = { frontmatter, preamble, sections };
1078
+ memo = { md, page };
1079
+ return page;
1080
+ }
1081
+ function frontmatterSection(p) {
1082
+ const lines = p.frontmatter ? p.frontmatter.split("\n") : [];
1083
+ return { heading: "", key: "frontmatter", lines, head: [], rows: lines.map((l) => ({ lines: [l], table: null, group: null })), tail: [] };
1084
+ }
1085
+ function findSection(p, want) {
1086
+ const w = want.trim().toLowerCase(), ws = snake(want);
1087
+ if (w === "frontmatter") return frontmatterSection(p);
1088
+ return p.sections.find((s) => s.key === w) ?? p.sections.find((s) => s.heading.toLowerCase() === w) ?? p.sections.find((s) => ws !== "" && snake(s.heading) === ws) ?? null;
1089
+ }
1090
+ var keyList = (p, counts = false) => [...p.sections.map((s) => counts && s.rows.length ? `${s.key} (${s.rows.length})` : s.key), "frontmatter"].join(", ");
1091
+ var titleOf = (p, path) => p.preamble.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() || path;
1092
+ function clip(s, n, mark = "\u2026") {
1093
+ if (s.length <= n) return s;
1094
+ const end = /[\uD800-\uDBFF]/.test(s[n - 2]) ? n - 2 : n - 1;
1095
+ return `${s.slice(0, end)}${mark}`;
1096
+ }
1097
+ var indentOf = (l) => l.length - l.trimStart().length;
1098
+ var BLOCK_SCALAR = /(?:^|:|-)\s*[|>][-+0-9]*\s*$/;
1099
+ var LIST_ITEM = /^( *)-(?: |$)/;
1100
+ function cutLists(lines) {
1101
+ const out = [];
1102
+ let i = 0;
1103
+ while (i < lines.length) {
1104
+ const l = lines[i], m = LIST_ITEM.exec(l);
1105
+ if (!m) {
1106
+ out.push(l);
1107
+ i++;
1108
+ if (BLOCK_SCALAR.test(l)) {
1109
+ const ind2 = indentOf(l);
1110
+ while (i < lines.length && (blank(lines[i]) || indentOf(lines[i]) > ind2)) out.push(lines[i++]);
1111
+ }
1112
+ continue;
1113
+ }
1114
+ const ind = m[1].length, items = [];
1115
+ while (i < lines.length && indentOf(lines[i]) === ind && LIST_ITEM.test(lines[i])) {
1116
+ const item = [lines[i++]];
1117
+ while (i < lines.length && (indentOf(lines[i]) > ind || blank(lines[i]) && i + 1 < lines.length && indentOf(lines[i + 1]) > ind)) item.push(lines[i++]);
1118
+ items.push(BLOCK_SCALAR.test(item[0]) ? item : [item[0], ...cutLists(item.slice(1))]);
1119
+ }
1120
+ const size = (k2) => items.slice(0, k2).map((it) => it.join("\n")).join("\n").length;
1121
+ if (size(items.length) <= FM_LIST_CHARS) {
1122
+ out.push(...items.flat());
1123
+ continue;
1124
+ }
1125
+ let k = 0;
1126
+ while (k < items.length && size(k + 1) <= FM_LIST_CHARS) k++;
1127
+ k = Math.max(k, FM_LIST_MIN);
1128
+ out.push(...items.slice(0, k).flat());
1129
+ if (k < items.length) out.push(`${" ".repeat(ind)}# \u2026 ${items.length - k} more; section: "frontmatter"`);
1130
+ }
1131
+ return out;
1132
+ }
1133
+ function compactFrontmatter(yaml) {
1134
+ const lines = yaml.split("\n"), kept = [];
1135
+ for (let i = 0; i < lines.length; i++) {
1136
+ if (/^generated:/.test(lines[i])) {
1137
+ while (i + 1 < lines.length && (indentOf(lines[i + 1]) > 0 || blank(lines[i + 1]))) i++;
1138
+ continue;
1139
+ }
1140
+ kept.push(lines[i]);
1141
+ }
1142
+ return cutLists(kept).join("\n");
1143
+ }
1144
+ function compactView(md, path, budget) {
1145
+ const p = parsePage(md);
1146
+ const out = [];
1147
+ if (md.startsWith("---\n") && p.frontmatter !== "") out.push("---", compactFrontmatter(p.frontmatter), "---", "");
1148
+ if (p.preamble) out.push(p.preamble, "");
1149
+ out.push(`Compact view: this page is ${fmt(md.length)} characters and one answer holds ${fmt(budget)}, so each section shows its first ${PREVIEW_ROWS} rows. Sections: ${keyList(p, true)}. Read one with cat("${path}", section: "<key>"); offset and limit page through a long one.`, "");
1150
+ for (const s of p.sections) {
1151
+ out.push(`## ${s.heading}`);
1152
+ const more = `\u2026 (section: "${s.key}")`;
1153
+ if (!s.rows.length) {
1154
+ const text2 = s.head.filter((l) => !blank(l)).join("\n");
1155
+ if (text2) out.push(clip(text2, PROSE_CLIP, more));
1156
+ } else {
1157
+ const lead = s.head.find((l) => !blank(l) && !l.startsWith("|") && !l.startsWith("### "));
1158
+ if (lead && lead.length <= HEAD_LINE_MAX) out.push(lead);
1159
+ let group = null, table2 = null;
1160
+ for (const r of s.rows.slice(0, PREVIEW_ROWS)) {
1161
+ if (r.group && r.group !== group) {
1162
+ out.push(r.group);
1163
+ group = r.group;
1164
+ }
1165
+ const t = r.table ? r.table.join("\n") : null;
1166
+ if (r.table && t !== table2) out.push(...r.table);
1167
+ table2 = t;
1168
+ out.push(clip(r.lines[0], ROW_CLIP));
1169
+ }
1170
+ 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
+ else {
1172
+ const text2 = s.tail.filter((l) => !blank(l)).join("\n");
1173
+ if (text2) out.push(clip(text2, PROSE_CLIP, more));
1174
+ }
1175
+ }
1176
+ out.push("");
1177
+ }
1178
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "")}
1179
+ `;
1180
+ }
1181
+ var dropLeadingBlank = (lines) => {
1182
+ let i = 0;
1183
+ while (i < lines.length && blank(lines[i])) i++;
1184
+ return lines.slice(i);
1185
+ };
1186
+ function sectionView(md, path, want, offset, limit, budget, site) {
1187
+ const p = parsePage(md);
1188
+ const nl = (lines) => `${lines.join("\n")}
1189
+ `;
1190
+ const s = findSection(p, want);
1191
+ if (!s) return `No section "${want}" on ${path}. Sections: ${keyList(p)}.`;
1192
+ const fm = s.key === "frontmatter" && s.heading === "";
1193
+ const first = `${titleOf(p, path)}, section "${s.key}": ${site}${path}/`;
1194
+ const heading = fm ? [] : [`## ${s.heading}`];
1195
+ const n = s.rows.length, off = Math.max(0, Math.floor(offset ?? 0));
1196
+ const verbatim = nl(fm ? [first, "", "---", ...s.lines, "---"] : [first, "", ...heading, ...s.lines]);
1197
+ if (!n || off === 0 && limit === void 0 && verbatim.length <= budget) return verbatim;
1198
+ const tail = dropLeadingBlank(s.tail);
1199
+ if (off >= n) return nl([first, "", ...heading, "", ...tail.length ? tail : [`No rows after ${n} in section "${s.key}" of ${path}.`]]);
1200
+ const out = [first, "", ...heading, ...fm ? [] : [""]];
1201
+ let group = null, table2 = null;
1202
+ if (off === 0) {
1203
+ out.push(...dropLeadingBlank(s.head));
1204
+ group = s.rows[0].group;
1205
+ table2 = s.rows[0].table ? s.rows[0].table.join("\n") : null;
1206
+ }
1207
+ let len = out.join("\n").length, b = off;
1208
+ const end = limit === void 0 ? n : Math.min(n, off + Math.max(1, Math.floor(limit)));
1209
+ for (let r = off; r < end; r++) {
1210
+ const row = s.rows[r], add = [];
1211
+ let g = group, t = table2;
1212
+ if (row.group && row.group !== g) {
1213
+ add.push(...out.length && !blank(out[out.length - 1]) ? [""] : [], row.group, "");
1214
+ g = row.group;
1215
+ t = null;
1216
+ }
1217
+ const rt = row.table ? row.table.join("\n") : null;
1218
+ if (row.table && rt !== t) add.push(...row.table);
1219
+ t = rt;
1220
+ add.push(...row.lines);
1221
+ const size = add.join("\n").length + 1;
1222
+ if (r > off && len + size > budget - RESERVE) break;
1223
+ out.push(...add);
1224
+ len += size;
1225
+ group = g;
1226
+ table2 = t;
1227
+ b = r + 1;
1228
+ }
1229
+ const rows = `Rows ${off + 1}-${b} of ${n}.`;
1230
+ if (b < n) return nl([...out, "", `${rows} Next: cat("${path}", section: "${s.key}", offset: ${b})`]);
1231
+ if (!tail.length) return nl([...out, "", rows]);
1232
+ const withTail = nl([...out, "", ...tail, "", rows]);
1233
+ if (limit === void 0 && withTail.length <= budget) return withTail;
1234
+ return nl([...out, "", `${rows} The section's closing text: cat("${path}", section: "${s.key}", offset: ${n})`]);
1235
+ }
1236
+ var ANCHOR_SECTION = { field: ["fields", "field"], value: ["values", "value"], event: ["events", "event"], proc: ["procedures", "procedure"] };
1237
+ function anchorView(md, path, anchor, site) {
1238
+ const a = anchor.replace(/^#/, ""), m = /^(field|value|event|proc)-(.+)$/.exec(a);
1239
+ if (!m) return null;
1240
+ const [key, label] = ANCHOR_SECTION[m[1]];
1241
+ const p = parsePage(md);
1242
+ const s = p.sections.find((x) => x.key === key);
1243
+ if (!s) return null;
1244
+ const match = m[1] === "field" || m[1] === "value" ? (l) => l.startsWith(`| ${m[2]} |`) : (l) => l.startsWith(`- \`${m[2]}(`);
1245
+ const rows = s.rows.filter((r) => match(r.lines[0]));
1246
+ if (!rows.length) return null;
1247
+ return `${[`${titleOf(p, path)}, ${label} ${m[2]}: ${site}${path}/#${a}`, "", `## ${s.heading}`, ...rows[0].table ?? [], ...rows.flatMap((r) => r.lines)].join("\n")}
1248
+ `;
1249
+ }
1250
+ function pageView(md, path, a, budget, site) {
1251
+ const hash = path.indexOf("#");
1252
+ if (hash >= 0) {
1253
+ const clean = path.slice(0, hash), anchor = path.slice(hash);
1254
+ return anchorView(md, clean, anchor, site) ?? `(No row for ${anchor} on this page.)
1255
+
1256
+ ${pageView(md, clean, {}, budget, site)}`;
1257
+ }
1258
+ if (a.section !== void 0 && a.section.trim() !== "") return sectionView(md, path, a.section, a.offset, a.limit, budget, site);
1259
+ if (a.offset !== void 0 || a.limit !== void 0) {
1260
+ return `offset and limit read one section: cat("${path}", section: "<key>", offset: ${a.offset ?? 0}). Sections: ${keyList(parsePage(md))}.`;
1261
+ }
1262
+ if (md.length <= budget) return md;
1263
+ return compactView(md, path, budget);
1264
+ }
1265
+
894
1266
  // src/server.ts
895
- var VERSION = "0.3.0";
1267
+ var VERSION = "0.5.0";
896
1268
  var SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
897
1269
  var LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
1270
+ var BUDGET = budgetOf(process.env.BC_OBSERVATORY_MAX_CHARS);
1271
+ var B = (Number.isFinite(BUDGET) ? BUDGET : DEFAULT_BUDGET).toLocaleString("en-US");
898
1272
  var REPO = "https://github.com/waldo1001/waldo.BCObservatory";
899
1273
  var DAY = 864e5;
900
1274
  var cacheDir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", createHash("sha256").update(SITE).digest("hex").slice(0, 12));
@@ -934,6 +1308,12 @@ async function loadIndex() {
934
1308
  for (const s of manifest.shards) all.push(...JSON.parse(await shard(s.file, s.sha256)));
935
1309
  pages = all;
936
1310
  const idx = prepare(all.map(pageToRecord));
1311
+ if (manifest.learn) {
1312
+ try {
1313
+ prepare(learnRecords(JSON.parse(await shard(manifest.learn.file, manifest.learn.sha256))).map(pageToRecord), idx);
1314
+ } catch {
1315
+ }
1316
+ }
937
1317
  let sm = null;
938
1318
  try {
939
1319
  sm = JSON.parse(await dataFile("index/symbols-manifest.json"));
@@ -961,7 +1341,10 @@ var line = (r, tag = "") => `- ${r.title}${r.caption ? ` (captioned "${r.caption
961
1341
  var text = (t) => ({ content: [{ type: "text", text: t }] });
962
1342
  var symbolHit = (r, tag = "") => `- ${r.name} (${symbolLine(r).replace(/ · /g, ", ")}) path=${r.id}${tag}${r.text ? `
963
1343
  ${r.text}` : ""}`;
964
- var hitLine = (r, tag = "") => r.kind === "page" && r.page ? line(r.page, tag) : symbolHit(r, tag);
1344
+ var learnLine = (r, tag = "") => `- ${r.title} [learn, official${r.date ? `, ${r.date}` : ""}] url=${r.path}${tag}${r.path_label ? `
1345
+ in: ${r.path_label}${r.hub ? ` (hub: ${r.hub})` : ""}` : ""}
1346
+ ${r.summary}`;
1347
+ var hitLine = (r, tag = "") => r.kind === "page" && r.page ? r.type === "learn" ? learnLine(r.page, tag) : line(r.page, tag) : symbolHit(r, tag);
965
1348
  var MODEL = {
966
1349
  repo: "minishlab/potion-base-8M",
967
1350
  revision: "bf8b056651a2c21b8d2565580b8569da283cab23",
@@ -1061,7 +1444,8 @@ async function toolSearch(a) {
1061
1444
  ${hs.map((h) => `Hint: ${h.text}${h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : ""}`).join("\n")}` : "";
1062
1445
  if (!shown.length) return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.${note}${hintText}`;
1063
1446
  const how = semantic.length && keyword.length && mode === "hybrid" ? "keyword + meaning" : mode === "semantic" && semantic.length ? "meaning" : "keyword";
1064
- return `${shown.length} results, ${how}${note}${how === "keyword + meaning" || mode === "hybrid" ? ` (exact band: ${exactBand})` : ""} (read one with cat(path)):
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}):
1065
1449
  ${shown.map((r) => hitLine(r, tag(r.id))).join("\n")}${hintText}`;
1066
1450
  }
1067
1451
  async function toolLs(a) {
@@ -1082,7 +1466,9 @@ async function toolLs(a) {
1082
1466
  ... and ${files.length - MAX} more pages; use search() to narrow down` : ""}`;
1083
1467
  }
1084
1468
  async function toolCat(a) {
1085
- return pageMarkdown(a.path.replace(/#.*$/, ""));
1469
+ const [base, anchor] = a.path.split(/(?=#)/);
1470
+ const clean = `${base.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "")}${anchor ?? ""}`;
1471
+ return pageView(await pageMarkdown(clean.replace(/#.*$/, "")), clean, a, BUDGET, SITE);
1086
1472
  }
1087
1473
  async function toolGetObject(a) {
1088
1474
  await loadIndex();
@@ -1123,7 +1509,7 @@ async function toolGetObject(a) {
1123
1509
  const also = !country && twins.length ? `
1124
1510
 
1125
1511
  (Also in ${twins.join(", ")}: get_object("${type}", "${pick[0].objectId ?? pick[0].name}", country).)` : "";
1126
- return `${note}${await pageMarkdown(pick[0].id)}${also}`;
1512
+ return `${note}${pageView(await pageMarkdown(pick[0].id), pick[0].id, a, BUDGET, SITE)}${a.section ? "" : also}`;
1127
1513
  }
1128
1514
  if (pick.length > 1) return `${pick.length} ${type} objects match "${a.idOrName}"; read one with cat(path):
1129
1515
  ${pick.map((r) => `- ${r.title} path=${r.id}`).join("\n")}`;
@@ -1144,7 +1530,8 @@ async function toolDiffObject(a) {
1144
1530
  ${JSON.stringify(o, null, 1)}`;
1145
1531
  }
1146
1532
  async function toolLocalization(a) {
1147
- return pageMarkdown(`localizations/${a.country.toLowerCase()}`);
1533
+ const p = `localizations/${a.country.toLowerCase()}`;
1534
+ return pageView(await pageMarkdown(p), p, a, BUDGET, SITE);
1148
1535
  }
1149
1536
  async function toolWhatsNew(a, today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
1150
1537
  await loadIndex();
@@ -1176,15 +1563,16 @@ ${a.message}
1176
1563
  (sent from the bc-observatory MCP server ${VERSION})`;
1177
1564
  return `Open this link to file the issue (no sign-in through the agent): ${REPO}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}&labels=feedback`;
1178
1565
  }
1566
+ var VIEW = { section: z.string().optional(), offset: z.number().int().min(0).max(1e5).optional(), limit: z.number().int().min(1).max(5e3).optional() };
1179
1567
  function createServer() {
1180
1568
  const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
1181
- 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)."
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."
1182
1570
  });
1183
1571
  server.registerTool(
1184
1572
  "search",
1185
1573
  {
1186
1574
  title: "Search the knowledge base",
1187
- description: "Search every page (title, AL object name and caption, summary, tags) with the site's own scorer: per query word title or caption 3, tags 2, summary 1, through the exact word, a prefix, the plural, a synonym (client = customer, G/L = general ledger) or one typo; a name equal to the query +10; an object reference ('t36', 'cu 80', 'table 11300 BE') or a bare id ('36') goes straight to the object; base app before first-party app before country layer, then an object's importance (what references, calls and documents it); hubs by size and review state. Hybrid by default: the exact band (references and exact names) stays on top and the rest is fused with a meaning ranking, so a paraphrase still finds the page; each hit says [keyword], [meaning] or [both]. mode 'keyword' for the scorer alone, 'semantic' for meaning only. Fields, published events, global procedures and enum values (W1 and apps, the major the object pages are built from) are searchable too ('OnAfterPostSalesDoc', 'Posting Date', or a prefix: 'event: OnAfterPost', 'field: Posting Date', 'proc: CopyToTempLines'); a symbol hit names its object and its path ends in #event-<Name>, #field-<id>, #proc-<Name> or #value-<ordinal>. 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).",
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).",
1188
1576
  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() }
1189
1577
  },
1190
1578
  async (a) => text(await toolSearch(a))
@@ -1196,13 +1584,13 @@ function createServer() {
1196
1584
  );
1197
1585
  server.registerTool(
1198
1586
  "cat",
1199
- { title: "Read a page", description: "The full markdown of a page (frontmatter with evidence and links, then the body), by its path from search or ls.", inputSchema: { path: z.string() } },
1587
+ { title: "Read a page", description: `A page's markdown by its path from search or ls: frontmatter with evidence and links, then the body. A page over ${B} characters comes back compact with its section keys; section (or 'frontmatter'), offset and limit read one part. A symbol path ('objects/codeunit/80#event-OnAfterPostSalesDoc') returns that member's row.`, inputSchema: { path: z.string(), ...VIEW } },
1200
1588
  async (a) => text(await toolCat(a))
1201
1589
  );
1202
1590
  server.registerTool("get_object", {
1203
1591
  title: "Look up an AL object",
1204
- description: "An AL object of W1, a Microsoft first-party app or a country layer by type and id, reference or name, e.g. ('table', '18'), ('table', 't36'), ('table', '36-be'), ('codeunit', 'Sales-Post'), ('page', 'Customers') by its caption, ('table', 'VAT VIES Correction') or with country 'BE': fields, keys, events, public procedures, obsolete state, versions, countries that replace it, Learn pages. A W1 object wins over its country twins unless a country is given; two candidates of the same standing are listed with their paths.",
1205
- inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional() }
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.`,
1593
+ inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional(), ...VIEW }
1206
1594
  }, async (a) => text(await toolGetObject(a)));
1207
1595
  server.registerTool("diff_object", {
1208
1596
  title: "What changed in an object",
@@ -1211,12 +1599,12 @@ function createServer() {
1211
1599
  }, async (a) => text(await toolDiffObject(a)));
1212
1600
  server.registerTool(
1213
1601
  "localization",
1214
- { title: "A country localization", description: "What a country layer (BE, NL, DE, ...) adds to or changes in W1, with its Learn local functionality hub.", inputSchema: { country: z.string().length(2) } },
1602
+ { title: "A country localization", description: `What a country layer (BE, NL, DE, ...) adds to or changes in W1, with its Learn local functionality hub. Over ${B} characters it comes back compact; section, offset and limit read one section, as in cat.`, inputSchema: { country: z.string().length(2), ...VIEW } },
1215
1603
  async (a) => text(await toolLocalization(a))
1216
1604
  );
1217
1605
  server.registerTool("whats_new", {
1218
1606
  title: "What is new",
1219
- description: "Videos, posts, roadmap features, AL extension releases (type 'release') and code changes (merged pull requests of BCApps, AL-Go and BCQuality, type 'change') dated on or after a date (YYYY-MM-DD) and up to today, newest first; a roadmap feature's date is its GA month, so features scheduled after today are counted at the end and listed with include_future: true.",
1607
+ description: "Videos, posts, roadmap features, AL extension releases (type 'release') and merged pull requests of BCApps, AL-Go and BCQuality (type 'change') dated on or after a date (YYYY-MM-DD) up to today, newest first. A roadmap feature's date is its GA month: features after today are counted at the end and listed with include_future: true.",
1220
1608
  inputSchema: { since: z.string(), type: z.string().optional(), limit: z.number().int().min(1).max(100).optional(), include_future: z.boolean().optional() }
1221
1609
  }, async (a) => text(await toolWhatsNew(a)));
1222
1610
  server.registerTool("blog_footprint", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bc-observatory",
3
- "version": "0.3.0",
3
+ "version": "0.5.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": {