bc-observatory 0.2.0 → 0.4.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.
package/dist/server.js CHANGED
@@ -1,19 +1,6 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * bc-observatory MCP server (PLAN 4.7, D35): BC Observatory's knowledge base for agents, over stdio.
4
- *
5
- * Data: the site's search index (index-manifest.json + pages-<n>.json, cached by sha256 in
6
- * ~/.cache/bc-observatory/<site hash>/, refreshed daily) and its markdown twins (<path>.md). Set
7
- * BC_OBSERVATORY_LOCAL=<repo checkout> to read content/ and data/ from disk instead (offline, tests), or
8
- * BC_OBSERVATORY_SITE to point at another deployment.
9
- *
10
- * Tools: search, ls, cat, get_object, diff_object, localization, whats_new, blog_footprint, feedback.
11
- *
12
- * Search is hybrid (D63): MiniSearch keywords fused with static embeddings (model2vec potion-base-8M, MIT) by
13
- * reciprocal rank, so "client" finds Customer. The model (30 MB) is downloaded once on the first search, pinned by
14
- * revision and checked by SHA-256, into ~/.cache/bc-observatory/models/. BC_OBSERVATORY_EMBEDDINGS=0 turns it off;
15
- * BC_OBSERVATORY_MODEL_DIR uses a model on disk (offline, tests). Without a model, search is keyword-only.
16
- */
2
+
3
+ // src/server.ts
17
4
  import { createHash } from "node:crypto";
18
5
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
19
6
  import { homedir } from "node:os";
@@ -21,272 +8,1608 @@ import { join, resolve } from "node:path";
21
8
  import { fileURLToPath } from "node:url";
22
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
- import MiniSearch from "minisearch";
25
11
  import { z } from "zod";
26
- import { dot, embed, parseModel } from "./embed.js";
27
- const VERSION = "0.2.0";
28
- const SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
29
- const LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
30
- const REPO = "https://github.com/waldo1001/waldo.BCObservatory";
31
- const DAY = 86_400_000;
32
- // ---------------------------------------------------------------------------------------------- data access
33
- const cacheDir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", createHash("sha256").update(SITE).digest("hex").slice(0, 12));
34
- async function fetchText(url) {
35
- const res = await fetch(url, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
36
- if (!res.ok)
37
- throw new Error(`HTTP ${res.status} for ${url}`);
38
- return res.text();
39
- }
40
- /** A data file (index manifest, shard, diff): from disk in local mode, else from the site. */
12
+
13
+ // ../search/src/types.ts
14
+ var OBJECT_TYPES = [
15
+ "table",
16
+ "tableextension",
17
+ "page",
18
+ "pageextension",
19
+ "codeunit",
20
+ "report",
21
+ "reportextension",
22
+ "query",
23
+ "xmlport",
24
+ "enum",
25
+ "enumextension",
26
+ "interface",
27
+ "permissionset",
28
+ "permissionsetextension",
29
+ "entitlement",
30
+ "profile",
31
+ "controladdin",
32
+ "pagecustomization"
33
+ ];
34
+ var KIND_OF = { fields: "field", events: "event", procs: "proc", values: "value" };
35
+
36
+ // ../search/src/normalize.ts
37
+ var fold = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
38
+ var TOKEN = /[\p{L}\p{N}]+(?:[.\-/][\p{L}\p{N}]+)*/gu;
39
+ var keep = (t) => t.length > 1 || /\d/.test(t);
40
+ function tokenize(s) {
41
+ const out = [];
42
+ for (const m of fold(s).matchAll(TOKEN)) if (keep(m[0])) out.push(m[0]);
43
+ return out;
44
+ }
45
+ function parts(token) {
46
+ if (!/[.\-/]/.test(token)) return [];
47
+ return token.split(/[.\-/]/).filter(keep);
48
+ }
49
+ function camelParts(raw) {
50
+ if (!/[a-z][A-Z]|[A-Z]{2}[a-z]/.test(raw)) return [];
51
+ return (raw.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []).map((w) => fold(w)).filter(keep);
52
+ }
53
+ function indexTokens(raw, camel = false) {
54
+ const out = /* @__PURE__ */ new Set();
55
+ for (const t of tokenize(raw)) {
56
+ out.add(t);
57
+ for (const p of parts(t)) out.add(p);
58
+ }
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];
61
+ }
62
+ function stem(t) {
63
+ if (t.length < 5 || /\d/.test(t) || /[.\-/]/.test(t)) return t;
64
+ let s = t;
65
+ if (s.endsWith("ies")) s = `${s.slice(0, -3)}y`;
66
+ else if (/(ss|x|z|ch|sh)es$/.test(s)) s = s.slice(0, -2);
67
+ else if (s.endsWith("s") && !/(ss|us|is)$/.test(s)) s = s.slice(0, -1);
68
+ if (s.length >= 6 && s.endsWith("ing")) s = s.slice(0, -3);
69
+ else if (s.length >= 5 && s.endsWith("ed")) s = s.slice(0, -2);
70
+ return s;
71
+ }
72
+ function editDistance(a, b, max = 2) {
73
+ if (Math.abs(a.length - b.length) > max) return max + 1;
74
+ const n = a.length, m = b.length;
75
+ let prev2 = new Array(m + 1).fill(0), prev = Array.from({ length: m + 1 }, (_, j) => j), cur = new Array(m + 1).fill(0);
76
+ for (let i = 1; i <= n; i++) {
77
+ cur[0] = i;
78
+ let best2 = cur[0];
79
+ for (let j = 1; j <= m; j++) {
80
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
81
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
82
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + 1);
83
+ cur[j] = v;
84
+ if (v < best2) best2 = v;
85
+ }
86
+ if (best2 > max) return max + 1;
87
+ [prev2, prev, cur] = [prev, cur, prev2];
88
+ }
89
+ return prev[m];
90
+ }
91
+
92
+ // ../search/src/synonyms.ts
93
+ var ABBR = {
94
+ t: "table",
95
+ te: "tableextension",
96
+ p: "page",
97
+ pe: "pageextension",
98
+ c: "codeunit",
99
+ cu: "codeunit",
100
+ r: "report",
101
+ q: "query",
102
+ e: "enum",
103
+ ee: "enumextension",
104
+ x: "xmlport",
105
+ i: "interface",
106
+ ps: "permissionset",
107
+ cod: "codeunit",
108
+ tab: "table",
109
+ pag: "page",
110
+ rep: "report",
111
+ xml: "xmlport",
112
+ enu: "enum",
113
+ pse: "permissionsetextension"
114
+ };
115
+ var SYNONYM_PAIRS = [
116
+ ["cust", "customer"],
117
+ ["vend", "vendor"],
118
+ ["g/l", "general ledger"],
119
+ ["gl", "general ledger"],
120
+ ["fa", "fixed asset"],
121
+ ["no", "number"],
122
+ ["qty", "quantity"],
123
+ ["amt", "amount"],
124
+ ["jnl", "journal"],
125
+ ["sku", "stockkeeping unit"],
126
+ ["uom", "unit of measure"],
127
+ ["po", "purchase order"],
128
+ ["so", "sales order"],
129
+ ["cu", "codeunit"],
130
+ ["inv", "invoice"],
131
+ ["invt", "inventory"],
132
+ ["cr", "credit"],
133
+ ["dim", "dimension"],
134
+ ["acc", "account"],
135
+ ["acct", "account"],
136
+ ["doc", "document"],
137
+ ["ic", "intercompany"],
138
+ ["bom", "bill of materials"],
139
+ ["wip", "work in process"],
140
+ ["mfg", "manufacturing"],
141
+ ["prod", "production"],
142
+ ["purch", "purchase"],
143
+ ["whse", "warehouse"],
144
+ ["pmt", "payment"],
145
+ ["rcpt", "receipt"],
146
+ ["shpt", "shipment"],
147
+ ["curr", "currency"],
148
+ ["exch", "exchange"],
149
+ ["gen", "general"],
150
+ ["bus", "business"],
151
+ ["prepmt", "prepayment"],
152
+ ["req", "requisition"],
153
+ ["mgt", "management"],
154
+ ["mgmt", "management"],
155
+ ["hdr", "header"],
156
+ ["job", "project"],
157
+ ["emp", "employee"],
158
+ ["res", "resource"],
159
+ ["pstd", "posted"]
160
+ ];
161
+ var SYNONYMS_ONE_WAY = [["client", "customer"], ["supplier", "vendor"], ["stock", "inventory"], ["debtor", "customer"], ["creditor", "vendor"]];
162
+ var SYSTEM_ALIASES = {
163
+ finance: ["general ledger", "g/l", "accounting", "vat", "bank", "cash flow", "dimensions", "posting groups", "cost accounting", "consolidation", "intercompany"],
164
+ sales: ["sales order", "customer", "quote", "invoice", "credit memo", "prices", "discounts", "receivables", "reminders"],
165
+ purchasing: ["purchase order", "vendor", "payables", "requisition", "approval"],
166
+ inventory: ["item", "item tracking", "costing", "planning", "mrp", "stockkeeping", "transfer order", "availability", "quality management"],
167
+ warehouse: ["bin", "pick", "put-away", "shipment", "receipt", "wms", "directed put-away"],
168
+ manufacturing: ["production order", "bom", "routing", "capacity", "work center", "subcontracting"],
169
+ projects: ["job", "jobs", "project", "resource", "time sheet", "wip"],
170
+ service: ["service order", "service contract", "service item"],
171
+ assembly: ["assembly order", "assemble-to-order", "kit"],
172
+ "fixed-assets": ["fa", "depreciation", "insurance", "maintenance"],
173
+ crm: ["contact", "campaign", "opportunity", "segment", "interaction", "outlook"],
174
+ hr: ["employee", "absence"],
175
+ sustainability: ["esg", "emissions", "carbon", "csrd"],
176
+ reporting: ["report", "power bi", "financial reports", "account schedules", "analysis mode", "excel", "word layout", "query"],
177
+ copilot: ["copilot", "agent", "ai", "mcp", "sales order agent", "payables agent", "chat", "prompt", "azure openai", "autofill", "summarize"],
178
+ integration: ["api", "odata", "web service", "dataverse", "dynamics 365 sales", "shopify", "e-document", "edi", "power automate", "power apps", "fabric", "data mirroring", "connector"],
179
+ administration: ["admin center", "tenant", "environment", "company", "permission", "security group", "setup", "assisted setup", "data migration", "retention", "profile", "role center"],
180
+ localization: ["country", "local functionality", "intrastat", "sepa", "e-invoicing", "tax", "1099", "gdpdu", "qr-bill"],
181
+ development: ["al", "extension", "codeunit", "table", "page", "event", "interface", "namespace", "al language", "vs code", "analyzer", "compiler", "testability", "permission set"],
182
+ platform: ["al-go", "pipeline", "docker", "container", "telemetry", "application insights", "performance", "database", "sql", "upgrade", "update", "release", "wave", "deprecation", "obsolete", "web client", "user experience"]
183
+ };
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"]);
185
+ var norm = (s) => s.toLowerCase().replace(/\.(?=\s|$)/g, "").trim();
186
+ var table = null;
187
+ function synonymsOf(word) {
188
+ table ??= (() => {
189
+ const m = /* @__PURE__ */ new Map();
190
+ const add = (a, b) => {
191
+ const l = m.get(a);
192
+ if (l) {
193
+ if (!l.includes(b)) l.push(b);
194
+ } else m.set(a, [b]);
195
+ };
196
+ for (const [a, b] of SYNONYM_PAIRS) {
197
+ add(norm(a), norm(b));
198
+ add(norm(b), norm(a));
199
+ }
200
+ for (const [a, b] of SYNONYMS_ONE_WAY) add(norm(a), norm(b));
201
+ return m;
202
+ })();
203
+ return table.get(norm(word)) ?? [];
204
+ }
205
+ function synonymPhrases() {
206
+ synonymsOf("");
207
+ return [...table.keys()].filter((k) => k.includes(" "));
208
+ }
209
+ function systemOf(word) {
210
+ const w = word.toLowerCase().trim();
211
+ if (SYSTEM_ALIASES[w]) return w;
212
+ for (const [id, aliases] of Object.entries(SYSTEM_ALIASES)) if (aliases.includes(w)) return id;
213
+ return null;
214
+ }
215
+
216
+ // ../search/src/parse.ts
217
+ var TYPE_WORDS = Object.fromEntries(
218
+ OBJECT_TYPES.flatMap((t) => [[t, t], [`${t}s`, t], ...t === "query" ? [["queries", t]] : []])
219
+ );
220
+ var typeOfWord = (w) => TYPE_WORDS[w.toLowerCase()] ?? ABBR[w.toLowerCase()] ?? null;
221
+ var KINDS = { field: "field", fields: "field", event: "event", events: "event", proc: "proc", procs: "proc", procedure: "proc", procedures: "proc", value: "value", values: "value" };
222
+ function term(text2, phrase = false, raw = text2) {
223
+ const alts = [...synonymsOf(text2)];
224
+ const camel = camelParts(raw);
225
+ 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
+ }
228
+ function parseQuery(raw, opts) {
229
+ 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: [] };
231
+ let s = raw.trim();
232
+ const phrases = [];
233
+ s = s.replace(/"([^"]*)"/g, (_, p) => {
234
+ const t = tokenize(p);
235
+ if (t.length) phrases.push({ text: t.join(" "), raw: p });
236
+ return " ";
237
+ }).trim();
238
+ const k = /^(fields?|events?|procs?|procedures?|values?)\s*:\s*/i.exec(s);
239
+ if (k) {
240
+ q.kind = KINDS[k[1].toLowerCase()];
241
+ s = s.slice(k[0].length).trim();
242
+ }
243
+ if (!q.kind && !phrases.length) {
244
+ const m = /^([a-z]+)\s*(\d+)(?:(?:\s+|\s*-\s*)([a-z]{2}))?$/i.exec(s);
245
+ if (m) {
246
+ const type = typeOfWord(m[1]);
247
+ const cc = m[3]?.toLowerCase() ?? null;
248
+ if (type && (!cc || countries.has(cc))) {
249
+ q.ref = { type, id: Number(m[2]), country: cc };
250
+ if (cc) q.countries.push(cc);
251
+ return q;
252
+ }
253
+ const version = /^(bc|v)$/i.test(m[1]) && /^(2[3-9]|3\d)$/.test(m[2]);
254
+ if (!type && !version && m[1].length <= 4 && !cc) q.warnings.push(`unknown abbreviation ${m[1].toLowerCase()}`);
255
+ }
256
+ if (/^\d+$/.test(s)) {
257
+ q.number = Number(s);
258
+ return q;
259
+ }
260
+ }
261
+ s = s.replace(/(?:^|\s)(?:bc|v)\s?(2[3-9]|3\d)(?=\s|$)/i, (_, v) => {
262
+ q.major = v;
263
+ return " ";
264
+ }).trim();
265
+ q.question = /^(how|what|why|where|when|which)\b/i.test(s) || /\bchanged in\b/i.test(s);
266
+ const words = s.split(/\s+/).filter(Boolean);
267
+ const typeAt = (i) => i >= 0 && i < words.length && !!TYPE_WORDS[words[i].toLowerCase()];
268
+ const typeWords = [];
269
+ const rest = [];
270
+ words.forEach((w, i) => {
271
+ const lw = w.toLowerCase();
272
+ if (TYPE_WORDS[lw]) {
273
+ q.types.push(TYPE_WORDS[lw]);
274
+ typeWords.push(w);
275
+ return;
276
+ }
277
+ if (words.length === 1 && lw.length > 1 && ABBR[lw]) {
278
+ q.types.push(ABBR[lw]);
279
+ typeWords.push(w);
280
+ return;
281
+ }
282
+ if (/^[a-z]{2}$/i.test(w) && countries.has(lw) && (w === w.toUpperCase() || typeAt(i - 1) || typeAt(i + 1))) {
283
+ if (!q.countries.includes(lw)) q.countries.push(lw);
284
+ return;
285
+ }
286
+ rest.push(w);
287
+ });
288
+ if (!rest.length && !phrases.length && !q.countries.length && !q.kind) rest.push(...typeWords);
289
+ let toks = [];
290
+ for (const w of rest) {
291
+ const t = tokenize(w);
292
+ for (const x of t) toks.push({ text: x, raw: t.length === 1 ? w.replace(/[^\p{L}\p{N}.\-/]/gu, "") : x });
293
+ }
294
+ if (q.question) toks = toks.filter((t) => !QUESTION_WORDS.has(t.text) && !(t.text.length === 1));
295
+ const multi = synonymPhrases().map((p) => p.split(" ")).sort((a, b) => b.length - a.length);
296
+ for (let i = 0; i < toks.length; i++) {
297
+ const hit = multi.find((p) => p.every((w, j) => toks[i + j]?.text === w));
298
+ if (hit) {
299
+ q.terms.push(term(hit.join(" "), true));
300
+ i += hit.length - 1;
301
+ continue;
302
+ }
303
+ q.terms.push(term(toks[i].text, false, toks[i].raw));
304
+ }
305
+ for (const p of phrases) q.terms.push(p.text.includes(" ") ? term(p.text, true, p.raw) : term(p.text, false, p.raw));
306
+ return q;
307
+ }
308
+
309
+ // ../search/src/anchors.ts
310
+ var anchorOf = (kind, key) => `#${kind}-${key}`;
311
+
312
+ // ../search/src/score.ts
313
+ var nameOf = (title) => /"(.*)"/.exec(title)?.[1] ?? title;
314
+ function layerOf(p) {
315
+ if (p.type !== "object") return 0;
316
+ if (countryOf(p)) return 2;
317
+ return !p.app || p.app === "Base Application" ? 0 : 1;
318
+ }
319
+ function countryOf(p) {
320
+ if (p.country) return String(p.country).toLowerCase();
321
+ if (p.type === "object" && p.object_id != null) return /-([a-z]{2})$/.exec(p.path)?.[1] ?? null;
322
+ return null;
323
+ }
324
+ function pageToRecord(p) {
325
+ const object = p.type === "object";
326
+ const country = object ? countryOf(p) : p.country ? String(p.country).toLowerCase() : null;
327
+ return {
328
+ id: p.path,
329
+ kind: "page",
330
+ type: p.type,
331
+ name: object ? p.name ?? nameOf(p.title) : p.title,
332
+ title: p.title,
333
+ ...p.caption ? { caption: p.caption } : {},
334
+ text: p.summary ?? "",
335
+ ...p.tags?.length ? { tags: p.tags } : {},
336
+ ...object ? { objectType: p.object_type, objectId: p.object_id ?? null, app: p.app ?? null, inbound: p.inbound ?? 0, subscribers: p.subscribers ?? 0 } : {},
337
+ country,
338
+ layer: layerOf(p),
339
+ importance: 0,
340
+ system: p.system ?? null,
341
+ date: p.date ?? null,
342
+ ...p.members !== void 0 ? { members: p.members } : {},
343
+ ...p.narrative ? { narrative: p.narrative } : {},
344
+ ...p.present_in ? { major: p.present_in } : {},
345
+ tier: p.tier,
346
+ page: p
347
+ };
348
+ }
349
+ function symbolToRecord(kind, row, owner) {
350
+ const name = String(row[1]);
351
+ const key = kind === "field" || kind === "value" ? Number(row[2]) : name;
352
+ const base = {
353
+ id: `${owner.id}${anchorOf(kind, key)}`,
354
+ kind,
355
+ type: owner.type,
356
+ name,
357
+ title: name,
358
+ objectType: owner.objectType,
359
+ objectId: owner.objectId ?? null,
360
+ country: owner.country ?? null,
361
+ app: owner.app ?? null,
362
+ layer: owner.layer,
363
+ importance: owner.importance,
364
+ inbound: owner.inbound ?? 0,
365
+ system: owner.system ?? null,
366
+ major: owner.major,
367
+ tier: owner.tier,
368
+ owner: { path: owner.id, title: owner.title }
369
+ };
370
+ if (kind === "field") return { ...base, text: row[4] ?? "", extra: { id: Number(row[2]), type: String(row[3]) } };
371
+ if (kind === "event") return { ...base, text: row[4] ?? "", subscribers: Number(row[3]) || 0, extra: { kind: String(row[2]), subscribers: Number(row[3]) || 0, obsolete: row[5] ?? null } };
372
+ if (kind === "proc") return { ...base, text: row[4] ?? "", extra: { params: Number(row[2]) || 0, returns: row[3] ?? null, obsolete: row[5] ?? null } };
373
+ return { ...base, ...row[3] ? { caption: String(row[3]) } : {}, text: "", extra: { ordinal: Number(row[2]), caption: row[3] ?? null } };
374
+ }
375
+ function hasMajor(r, major) {
376
+ if (!r.major) return false;
377
+ const m = Number(major);
378
+ return r.major.split(/[\s,]+/).some((part) => {
379
+ const [a, b] = part.split("-").map(Number);
380
+ return b === void 0 || Number.isNaN(b) ? a === m : a <= m && m <= b;
381
+ });
382
+ }
383
+ var STATE = /* @__PURE__ */ new WeakMap();
384
+ var NORM = /* @__PURE__ */ new WeakMap();
385
+ var push = (m, k, v) => {
386
+ const l = m.get(k);
387
+ if (l) l.push(v);
388
+ else m.set(k, [v]);
389
+ };
390
+ function normOf(r) {
391
+ const cached = NORM.get(r);
392
+ if (cached) return cached;
393
+ const symbol = r.kind !== "page";
394
+ const main = /* @__PURE__ */ new Set([...indexTokens(r.name, symbol), ...indexTokens(r.title, symbol), ...r.caption ? indexTokens(r.caption) : []]);
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));
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 };
399
+ NORM.set(r, n);
400
+ return n;
401
+ }
402
+ var textOf = (r, n) => n.text ??= indexTokens(r.text ?? "");
403
+ function prepare(records, prev) {
404
+ const index2 = prev ?? { records: [], postings: /* @__PURE__ */ new Map(), vocab: /* @__PURE__ */ new Map(), byType: /* @__PURE__ */ new Map(), maxInbound: /* @__PURE__ */ new Map() };
405
+ let st = STATE.get(index2);
406
+ if (!st) {
407
+ st = { norms: [], stems: /* @__PURE__ */ new Map(), buckets: /* @__PURE__ */ new Map(), sorted: null, byObject: /* @__PURE__ */ new Map(), byId: /* @__PURE__ */ new Map(), byCountry: /* @__PURE__ */ new Map(), maxSubs: 0, matchers: /* @__PURE__ */ new WeakMap() };
408
+ STATE.set(index2, st);
409
+ }
410
+ for (const r of records) {
411
+ const i = index2.records.length;
412
+ index2.records.push(r);
413
+ const n = normOf(r);
414
+ st.norms[i] = n;
415
+ const toks = /* @__PURE__ */ new Set([...n.main, ...n.tags, ...n.text ?? []]);
416
+ for (const t of toks) {
417
+ const df = index2.vocab.get(t);
418
+ if (df === void 0) {
419
+ index2.vocab.set(t, 1);
420
+ const s = stem(t);
421
+ (st.stems.get(s) ?? st.stems.set(s, /* @__PURE__ */ new Set()).get(s)).add(t);
422
+ if (/^[a-z]{4,}$/.test(t)) push(st.buckets, `${t[0]}${t.length}`, t);
423
+ st.sorted = null;
424
+ } else index2.vocab.set(t, df + 1);
425
+ push(index2.postings, t, i);
426
+ }
427
+ push(index2.byType, r.kind === "page" ? r.type : r.kind, i);
428
+ if (r.kind === "page" && r.type === "object" && r.objectType) {
429
+ push(st.byObject, `${r.objectType}/${r.objectId ?? fold(r.name)}`, i);
430
+ if (r.objectId != null) push(st.byId, r.objectId, i);
431
+ index2.maxInbound.set(r.objectType, Math.max(index2.maxInbound.get(r.objectType) ?? 0, r.inbound ?? 0));
432
+ }
433
+ if (r.country && r.kind === "page") push(st.byCountry, r.country, i);
434
+ if (r.kind === "event") st.maxSubs = Math.max(st.maxSubs, r.subscribers ?? 0);
435
+ }
436
+ for (const r of index2.records) r.importance = importance(index2, r);
437
+ st.matchers = /* @__PURE__ */ new WeakMap();
438
+ return index2;
439
+ }
440
+ function importance(index2, r) {
441
+ if (!r.objectType) return 0;
442
+ const max = index2.maxInbound.get(r.objectType) ?? 0;
443
+ let v = max > 0 ? Math.log2(1 + (r.inbound ?? 0)) / Math.log2(1 + max) : 0;
444
+ if (r.kind === "event") {
445
+ const ms = STATE.get(index2)?.maxSubs ?? 0;
446
+ if (ms > 0) v += 0.5 * Math.log2(1 + (r.subscribers ?? 0)) / Math.log2(1 + ms);
447
+ }
448
+ return Math.min(1, v);
449
+ }
450
+ function sortedVocab(index2, st) {
451
+ return st.sorted ??= [...index2.vocab.keys()].sort();
452
+ }
453
+ function prefixed(sorted, p) {
454
+ let lo = 0, hi = sorted.length;
455
+ while (lo < hi) {
456
+ const mid = lo + hi >> 1;
457
+ if (sorted[mid] < p) lo = mid + 1;
458
+ else hi = mid;
459
+ }
460
+ const out = [];
461
+ for (let i = lo; i < sorted.length && sorted[i].startsWith(p); i++) out.push(sorted[i]);
462
+ return out;
463
+ }
464
+ function fuzzyCandidates(index2, word) {
465
+ const st = STATE.get(index2);
466
+ if (!st || word.length < 5 || !/^[a-z]+$/.test(word)) return [];
467
+ const max = word.length >= 9 ? 2 : 1, out = [];
468
+ for (let len = word.length - 2; len <= word.length + 2; len++) for (const t of st.buckets.get(`${word[0]}${len}`) ?? []) if (editDistance(word, t, max) <= max) out.push(t);
469
+ return out;
470
+ }
471
+ function matcherOf(index2, st, t) {
472
+ const words = /* @__PURE__ */ new Map(), phrases = [];
473
+ const set = (w, f) => {
474
+ const o = words.get(w);
475
+ if (!o || o.f < f.f) words.set(w, f);
476
+ };
477
+ const rarest = (ws) => {
478
+ let best2 = [], n = Infinity;
479
+ for (const w of ws) {
480
+ const forms = [.../* @__PURE__ */ new Set([w, ...st.stems.get(stem(w)) ?? []])].filter((x) => index2.vocab.has(x));
481
+ const c = forms.reduce((a, x) => a + (index2.vocab.get(x) ?? 0), 0);
482
+ if (c < n) {
483
+ n = c;
484
+ best2 = forms;
485
+ }
486
+ }
487
+ return best2;
488
+ };
489
+ const seeds = [];
490
+ if (t.phrase) {
491
+ phrases.push({ words: t.text.split(" "), f: 1, how: "exact" });
492
+ seeds.push(...rarest(t.text.split(" ")));
493
+ } else if (t.numeric) {
494
+ if (index2.vocab.has(t.text)) set(t.text, { f: 1, how: "exact" });
495
+ } else {
496
+ if (index2.vocab.has(t.text)) set(t.text, { f: 1, how: "exact" });
497
+ for (const w of st.stems.get(t.stem) ?? []) set(w, { f: 0.75, how: "stem" });
498
+ if (t.text.length >= 3) for (const w of prefixed(sortedVocab(index2, st), t.text)) set(w, { f: 0.75, how: "prefix" });
499
+ if (!words.size) for (const w of fuzzyCandidates(index2, t.text)) set(w, { f: 0.5, how: "fuzzy", via: w });
500
+ }
501
+ for (const alt of t.alts) {
502
+ if (alt.includes(" ")) {
503
+ const ws = alt.split(" ");
504
+ const camel = t.text === ws.join("") || ws.join("") === t.text.replace(/[^a-z0-9]/g, "");
505
+ phrases.push({ words: ws, f: camel ? 0.75 : 0.8, how: camel ? "prefix" : "syn", ...camel ? {} : { via: alt } });
506
+ seeds.push(...rarest(ws));
507
+ } else {
508
+ if (index2.vocab.has(alt)) set(alt, { f: 0.8, how: "syn", via: alt });
509
+ for (const w of st.stems.get(stem(alt)) ?? []) set(w, { f: 0.6, how: "syn", via: alt });
510
+ }
511
+ }
512
+ seeds.push(...words.keys());
513
+ return { term: t, words, phrases, seeds: [...new Set(seeds)] };
514
+ }
515
+ function matchersOf(index2, q) {
516
+ const st = STATE.get(index2);
517
+ let m = st.matchers.get(q);
518
+ if (!m) {
519
+ m = q.terms.map((t) => matcherOf(index2, st, t));
520
+ st.matchers.set(q, m);
521
+ }
522
+ return m;
523
+ }
524
+ function best(m, toks) {
525
+ let b = null;
526
+ for (const t of toks) {
527
+ const f = m.words.get(t);
528
+ if (f && (!b || f.f > b.f)) b = f;
529
+ }
530
+ for (const p of m.phrases) {
531
+ if (b && b.f >= p.f) continue;
532
+ const f = phraseForm(p.words, toks);
533
+ if (f) {
534
+ const v = p.f * f;
535
+ if (!b || v > b.f) b = { f: v, how: p.how, ...p.via ? { via: p.via } : {} };
536
+ }
537
+ }
538
+ return b;
539
+ }
540
+ function phraseForm(words, toks) {
541
+ let bestF = 0;
542
+ for (let i = 0; i + words.length <= toks.length; i++) {
543
+ let f = 1;
544
+ for (let j = 0; j < words.length && f; j++) {
545
+ const t = toks[i + j], w = words[j];
546
+ f = t === w ? f : stem(t) === stem(w) ? Math.min(f, 0.75) : 0;
547
+ }
548
+ if (f > bestF) bestF = f;
549
+ if (bestF === 1) break;
550
+ }
551
+ return bestF;
552
+ }
553
+ function nameMatch(eq, ms, plural) {
554
+ let pos = 0, f = 1, lastPrefix = false;
555
+ for (const m of ms) {
556
+ const ws = m.term.phrase ? m.term.text.split(" ") : null;
557
+ if (ws) {
558
+ for (const w of ws) {
559
+ const t2 = eq[pos++];
560
+ 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);
562
+ }
563
+ if (!f) return { eq: 0, starts: 0 };
564
+ lastPrefix = false;
565
+ continue;
566
+ }
567
+ const t = eq[pos++];
568
+ if (t === void 0) return { eq: 0, starts: 0 };
569
+ const form = m.words.get(t);
570
+ if (!form) {
571
+ const p = m.phrases.find((x) => x.words.every((w, j) => eq[pos - 1 + j] === w));
572
+ if (!p) return { eq: 0, starts: 0 };
573
+ pos += p.words.length - 1;
574
+ f = Math.min(f, p.f);
575
+ lastPrefix = p.how === "prefix";
576
+ continue;
577
+ }
578
+ f = Math.min(f, form.how === "stem" && plural ? 1 : form.f);
579
+ lastPrefix = form.how === "prefix";
580
+ }
581
+ if (pos === eq.length && !lastPrefix && (plural || f === 1)) return { eq: f, starts: 0 };
582
+ return { eq: 0, starts: pos <= eq.length ? f : 0 };
583
+ }
584
+ var NARRATIVE = { reviewed: 2, unreviewed: 0, none: -2, derived: -2 };
585
+ var TYPE_PRIOR = { table: 0.3, page: 0.2, codeunit: 0.2, report: 0.1 };
586
+ var LAYER = [1, 0.5, 0];
587
+ var OBJECT_WORDS = /\b(field|fields|event|events|procedure|procedures|proc|member|members)\b/i;
588
+ function scoreRecord(index2, r, q) {
589
+ const why = [];
590
+ const object = r.kind === "page" && r.type === "object", symbol = r.kind !== "page", objectish = object || symbol;
591
+ const none = { s: 0, why };
592
+ if (q.kind && r.kind !== q.kind) return none;
593
+ if (q.major && !hasMajor(r, q.major)) return none;
594
+ if (q.types.length && objectish && !q.types.includes(r.objectType)) return none;
595
+ const country = r.country ? r.country.toLowerCase() : null;
596
+ if (q.ref) {
597
+ if (!object || r.objectType !== q.ref.type || r.objectId !== q.ref.id) return none;
598
+ why.push("reference");
599
+ if (q.ref.country) return { s: country === q.ref.country ? 1e3 : country === null ? 900 : 850, why };
600
+ return { s: country === null ? 1e3 : 900, why };
601
+ }
602
+ const prior = object ? TYPE_PRIOR[r.objectType ?? ""] ?? 0 : 0;
603
+ const lift = (base) => base + (object ? LAYER[r.layer] : symbol ? 0 : 1) + (objectish ? 2 * r.importance : 0) + prior;
604
+ if (q.number !== null) {
605
+ if (!object || r.objectId !== q.number) return none;
606
+ why.push("id");
607
+ return { s: lift(8) + countryPoints(q, r, country), why };
608
+ }
609
+ if (!q.terms.length) {
610
+ if (!q.countries.length) return none;
611
+ if (r.type === "localization" && country && q.countries.includes(country)) {
612
+ why.push("name");
613
+ return { s: 20, why };
614
+ }
615
+ if (object && country && q.countries.includes(country)) return { s: 3 + 2 * r.importance + prior, why };
616
+ return none;
617
+ }
618
+ const n = normOf(r), ms = matchersOf(index2, q);
619
+ let s = 0, named = false, gate = !symbol || !!q.kind;
620
+ for (const m of ms) {
621
+ const main = best(m, n.main), tags = best(m, n.tags), text2 = best(m, textOf(r, n));
622
+ if (!main && !tags && !text2) return none;
623
+ if (main || tags) named = true;
624
+ if (symbol && !gate && n.eqs[0].some((t) => {
625
+ const f = m.words.get(t);
626
+ return f && (f.how === "exact" || f.how === "prefix");
627
+ })) gate = true;
628
+ if (symbol && /^on[a-z]/.test(m.term.text)) gate = true;
629
+ s += 3 * (main?.f ?? 0) + 2 * (tags?.f ?? 0) + (text2?.f ?? 0);
630
+ const via = [main, tags, text2].find((x) => x?.via && (x.how === "syn" || x.how === "fuzzy"))?.via;
631
+ if (via && !why.includes(`matched "${via}"`)) why.push(`matched "${via}"`);
632
+ }
633
+ if (!gate) return none;
634
+ let eq = 0, starts = 0, byName = false;
635
+ n.eqs.forEach((e, k) => {
636
+ const x = nameMatch(e, ms, objectish);
637
+ if (x.eq > eq) {
638
+ eq = x.eq;
639
+ byName = k === 0;
640
+ }
641
+ starts = Math.max(starts, x.starts);
642
+ });
643
+ if (eq) {
644
+ s += 10 * eq;
645
+ why.push(!byName ? "caption" : eq >= 0.75 ? "name" : "name~");
646
+ } else if (starts) {
647
+ s += 3 * starts;
648
+ why.push("starts");
649
+ }
650
+ if ((r.type === "topic" || r.type === "app") && !symbol && named) s += Math.log2((r.members ?? 0) + 1);
651
+ if ((r.type === "topic" || r.type === "app") && !symbol) s += NARRATIVE[r.narrative ?? (r.type === "app" ? "none" : "unreviewed")] ?? 0;
652
+ s += countryPoints(q, r, country);
653
+ 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 ")));
656
+ if (objectish && !lifted) {
657
+ s *= 0.6;
658
+ why.push("demoted");
659
+ }
660
+ return { s, why };
661
+ }
662
+ function countryPoints(q, r, country) {
663
+ if (!q.countries.length || !country) return 0;
664
+ if (q.countries.includes(country)) return 3;
665
+ return r.type === "object" ? -1 : 0;
666
+ }
667
+ var byRank = (a, b) => b.s - a.s || (b.r.date ?? "").localeCompare(a.r.date ?? "") || (b.r.members ?? 0) - (a.r.members ?? 0) || b.r.importance - a.r.importance || a.r.title.localeCompare(b.r.title);
668
+ function search(index2, q, opts = {}) {
669
+ const st = STATE.get(index2);
670
+ if (!st) return [];
671
+ let cand;
672
+ if (q.ref) cand = st.byObject.get(`${q.ref.type}/${q.ref.id}`) ?? [];
673
+ else if (q.number !== null) cand = st.byId.get(q.number) ?? [];
674
+ else if (!q.terms.length) cand = q.countries.flatMap((c) => st.byCountry.get(c) ?? []);
675
+ else {
676
+ const ms = matchersOf(index2, q), N = index2.records.length;
677
+ const count = new Uint8Array(N);
678
+ ms.forEach((m, k) => {
679
+ for (const w of m.seeds) for (const i of index2.postings.get(w) ?? []) if (count[i] === k) count[i] = k + 1;
680
+ });
681
+ const out = [];
682
+ for (let i = 0; i < N; i++) if (count[i] === ms.length) out.push(i);
683
+ cand = out;
684
+ }
685
+ const hits = [];
686
+ for (const i of cand) {
687
+ const r = index2.records[i];
688
+ if (opts.kinds && !opts.kinds.includes(r.kind)) continue;
689
+ if (opts.filter && !opts.filter(r)) continue;
690
+ const { s, why } = scoreRecord(index2, r, q);
691
+ if (s > 0) hits.push({ r, s, why, band: s >= 900 || why.includes("name") && !why.includes("demoted") ? "exact" : "match" });
692
+ }
693
+ hits.sort(byRank);
694
+ return opts.limit ? hits.slice(0, opts.limit) : hits;
695
+ }
696
+ 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}"` : ""}`;
702
+ return r.title;
703
+ }
704
+ function objectsAt(index2, type, id) {
705
+ return (STATE.get(index2)?.byObject.get(`${type}/${id}`) ?? []).map((i) => index2.records[i]);
706
+ }
707
+ function idsOf(index2, type) {
708
+ const out = /* @__PURE__ */ new Set();
709
+ for (const i of index2.byType.get("object") ?? []) {
710
+ const r = index2.records[i];
711
+ if (r.objectType === type && r.objectId != null) out.add(r.objectId);
712
+ }
713
+ return [...out];
714
+ }
715
+ function typoWords(index2, q) {
716
+ if (!STATE.get(index2)) return [];
717
+ return matchersOf(index2, q).filter((m) => m.words.size && [...m.words.values()].every((f) => f.how === "fuzzy")).map((m) => ({ term: m.term.text, words: [...m.words.keys()] }));
718
+ }
719
+ function namedBy(index2, r, q) {
720
+ if (!STATE.get(index2)) return 0;
721
+ 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);
723
+ }
724
+ function symbolRecords(kind, rows, ownerOf) {
725
+ const out = [];
726
+ for (const row of rows) {
727
+ const o = ownerOf(String(row[0]));
728
+ if (o) out.push(symbolToRecord(kind, row, o));
729
+ }
730
+ return out;
731
+ }
732
+ function countriesOf(index2) {
733
+ const st = STATE.get(index2);
734
+ return st ? [...st.byCountry.keys()].sort() : [];
735
+ }
736
+
737
+ // ../search/src/hints.ts
738
+ function hints(index2, q, hits) {
739
+ const out = [];
740
+ const unknown = q.warnings.map((w) => /^unknown abbreviation (\w+)$/.exec(w)?.[1]).find(Boolean);
741
+ const digits = /(\d+)\s*$/.exec(q.raw)?.[1];
742
+ if (unknown && digits) {
743
+ const seen = /* @__PURE__ */ new Set();
744
+ const words = [...Object.entries(ABBR), ...Object.entries(TYPE_WORDS).filter(([w, t]) => w === t)];
745
+ const cands = words.map(([w, type]) => ({ w, type, e: editDistance(unknown, w, 1) })).filter((c) => c.e <= 1).map((c) => ({ ...c, d: c.e + (c.w[0] === unknown[0] ? 0 : 0.5) + (c.w.length === unknown.length ? 0 : 0.25) })).sort((a, b) => a.d - b.d || a.w.length - b.w.length || a.w.localeCompare(b.w));
746
+ for (const c of cands) {
747
+ if (seen.has(c.type) || out.length >= 3) continue;
748
+ if (!objectsAt(index2, c.type, Number(digits)).length) continue;
749
+ seen.add(c.type);
750
+ out.push({ text: `Did you mean "${c.w} ${digits}" (${c.type} ${digits})?`, query: `${c.w} ${digits}` });
751
+ }
752
+ }
753
+ if (q.ref) {
754
+ const there = objectsAt(index2, q.ref.type, q.ref.id);
755
+ if (!there.length) {
756
+ const ids = idsOf(index2, q.ref.type).sort((a, b) => Math.abs(a - q.ref.id) - Math.abs(b - q.ref.id) || a - b).slice(0, 3).sort((a, b) => a - b);
757
+ out.push({ text: `No ${q.ref.type} ${q.ref.id}.${ids.length ? ` Nearest ids: ${ids.map((i) => `${q.ref.type} ${i}`).join(", ")}.` : ""}`, ...ids.length ? { query: `${q.ref.type} ${ids[0]}` } : {} });
758
+ } else if (q.ref.country && !there.some((r) => r.country === q.ref.country) && there.some((r) => !r.country)) {
759
+ const w1 = there.find((r) => !r.country);
760
+ out.push({ text: `${q.ref.country.toUpperCase()} has no ${q.ref.type} ${q.ref.id} of its own; the W1 ${q.ref.type} applies.`, path: w1.id });
761
+ }
762
+ }
763
+ for (const t of typoWords(index2, q)) {
764
+ const pagesWith = (w) => (index2.postings.get(w) ?? []).reduce((n, i) => n + (index2.records[i].kind === "page" ? 1 : 0), 0);
765
+ const words = t.words.map((w) => ({ w, n: pagesWith(w) })).sort((a, b) => b.n - a.n).slice(0, 3);
766
+ const swap = (w) => q.raw.replace(new RegExp(t.term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), w);
767
+ 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
+ }
769
+ 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;
778
+ }
779
+ }
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
+ }
782
+ void hits;
783
+ return out;
784
+ }
785
+
786
+ // src/embed.ts
787
+ var MAX_TOKENS = 512;
788
+ function parseModel(tokenizerJson, safetensors, config = {}) {
789
+ const t = JSON.parse(tokenizerJson);
790
+ if (t.model.type !== "WordPiece") throw new Error(`unsupported tokenizer model ${t.model.type}`);
791
+ const vocab = new Map(Object.entries(t.model.vocab));
792
+ const view = new DataView(safetensors.buffer, safetensors.byteOffset, safetensors.byteLength);
793
+ const headerLen = Number(view.getBigUint64(0, true));
794
+ const header = JSON.parse(new TextDecoder().decode(safetensors.subarray(8, 8 + headerLen)));
795
+ const name = Object.keys(header).find((k) => k !== "__metadata__");
796
+ if (!name) throw new Error("safetensors file has no tensor");
797
+ const { dtype, shape, data_offsets: [start, end] } = header[name];
798
+ if (dtype !== "F32" || shape.length !== 2) throw new Error(`unsupported tensor ${name}: ${dtype} ${shape.join("x")}`);
799
+ const bytes = safetensors.slice(8 + headerLen + start, 8 + headerLen + end);
800
+ const matrix = new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
801
+ if (matrix.length !== shape[0] * shape[1]) throw new Error("tensor size does not match its shape");
802
+ const unk = t.model.unk_token !== void 0 ? vocab.get(t.model.unk_token) ?? null : null;
803
+ return { dims: shape[1], vocab, unk, matrix, normalize: config.normalize !== false, maxWordChars: t.model.max_input_chars_per_word ?? 100, prefix: t.model.continuing_subword_prefix ?? "##" };
804
+ }
805
+ var CJK = [[19968, 40959], [13312, 19903], [131072, 173791], [173824, 177983], [177984, 178207], [178208, 183983], [63744, 64255], [194560, 195103]];
806
+ var isCjk = (cp) => CJK.some(([a, b]) => cp >= a && cp <= b);
807
+ var isPunct = (ch, cp) => cp >= 33 && cp <= 47 || cp >= 58 && cp <= 64 || cp >= 91 && cp <= 96 || cp >= 123 && cp <= 126 || new RegExp("\\p{P}", "u").test(ch);
808
+ function normalize(text2) {
809
+ let out = "";
810
+ for (const ch of text2) {
811
+ const cp = ch.codePointAt(0);
812
+ if (cp === 0 || cp === 65533) continue;
813
+ if (ch === " " || ch === "\n" || ch === "\r" || /\s/u.test(ch)) {
814
+ out += " ";
815
+ continue;
816
+ }
817
+ if (/[\p{Cc}\p{Cf}]/u.test(ch)) continue;
818
+ out += isCjk(cp) ? ` ${ch} ` : ch;
819
+ }
820
+ return out.normalize("NFD").replace(new RegExp("\\p{Mn}", "gu"), "").toLowerCase();
821
+ }
822
+ function preTokenize(text2) {
823
+ const words = [];
824
+ let cur = "";
825
+ for (const ch of text2) {
826
+ if (/\s/u.test(ch)) {
827
+ if (cur) words.push(cur);
828
+ cur = "";
829
+ continue;
830
+ }
831
+ if (isPunct(ch, ch.codePointAt(0))) {
832
+ if (cur) words.push(cur);
833
+ words.push(ch);
834
+ cur = "";
835
+ continue;
836
+ }
837
+ cur += ch;
838
+ }
839
+ if (cur) words.push(cur);
840
+ return words;
841
+ }
842
+ function tokenize2(m, text2) {
843
+ const ids = [];
844
+ for (const word of preTokenize(normalize(text2))) {
845
+ const chars = [...word];
846
+ if (chars.length > m.maxWordChars) continue;
847
+ const pieces = [];
848
+ let start = 0, bad = false;
849
+ while (start < chars.length) {
850
+ let end = chars.length, found;
851
+ while (start < end) {
852
+ const sub = (start > 0 ? m.prefix : "") + chars.slice(start, end).join("");
853
+ found = m.vocab.get(sub);
854
+ if (found !== void 0) break;
855
+ end--;
856
+ }
857
+ if (found === void 0) {
858
+ bad = true;
859
+ break;
860
+ }
861
+ pieces.push(found);
862
+ start = end;
863
+ }
864
+ if (!bad) {
865
+ for (const id of pieces) if (id !== m.unk) ids.push(id);
866
+ }
867
+ if (ids.length >= MAX_TOKENS) return ids.slice(0, MAX_TOKENS);
868
+ }
869
+ return ids;
870
+ }
871
+ function embed(m, text2) {
872
+ const v = new Float32Array(m.dims);
873
+ const ids = tokenize2(m, text2);
874
+ if (!ids.length) return v;
875
+ for (const id of ids) {
876
+ const off = id * m.dims;
877
+ for (let d = 0; d < m.dims; d++) v[d] += m.matrix[off + d];
878
+ }
879
+ for (let d = 0; d < m.dims; d++) v[d] /= ids.length;
880
+ if (m.normalize) {
881
+ let n = 0;
882
+ for (let d = 0; d < m.dims; d++) n += v[d] * v[d];
883
+ n = Math.sqrt(n);
884
+ if (n > 0) for (let d = 0; d < m.dims; d++) v[d] /= n;
885
+ }
886
+ return v;
887
+ }
888
+ function dot(a, b, bOffset = 0) {
889
+ let s = 0;
890
+ for (let d = 0; d < a.length; d++) s += a[d] * b[bOffset + d];
891
+ return s;
892
+ }
893
+
894
+ // src/sections.ts
895
+ var DEFAULT_BUDGET = 3e4;
896
+ var MIN_BUDGET = 5e3;
897
+ var PREVIEW_ROWS = 5;
898
+ var ROW_CLIP = 200;
899
+ var PROSE_CLIP = 800;
900
+ var HEAD_LINE_MAX = 120;
901
+ var FM_LIST_CHARS = 600;
902
+ var FM_LIST_MIN = 3;
903
+ var RESERVE = 300;
904
+ var OBJECT_KEYS = {
905
+ "Properties": "properties",
906
+ "Fields": "fields",
907
+ "Fields on this page": "controls",
908
+ "Actions": "actions",
909
+ "Keys": "keys",
910
+ "Values": "values",
911
+ "Events published": "events",
912
+ "Trigger event subscribers": "trigger_subscribers",
913
+ "Event subscriptions": "subscriptions",
914
+ "Procedures": "procedures",
915
+ "Triggers": "triggers",
916
+ "Relations": "relations",
917
+ "Referenced by": "referenced_by",
918
+ "Pages and codeunits on this table": "pages",
919
+ "Extended by": "extended_by",
920
+ "Calls": "calls",
921
+ "Called by": "called_by",
922
+ "Implements": "implements",
923
+ "Implemented by": "implemented_by",
924
+ "Recent changes": "changes",
925
+ "Across versions": "versions",
926
+ "Countries that replace it": "countries",
927
+ "Documented on Microsoft Learn": "learn",
928
+ "Deprecations": "deprecations",
929
+ "Ask your agent": "ask"
930
+ };
931
+ var snake = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
932
+ var blank = (l) => l.trim() === "";
933
+ var fmt = (n) => n.toLocaleString("en-US");
934
+ var FENCE = /^\s*```/;
935
+ var SEPARATOR = /^\|\s*:?-/;
936
+ var CAP_NOTE = /^- and \d+ more/;
937
+ function budgetOf(env) {
938
+ const n = env === void 0 || env.trim() === "" ? NaN : Number(env);
939
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_BUDGET;
940
+ if (n === 0) return Infinity;
941
+ return Math.max(MIN_BUDGET, Math.floor(n));
942
+ }
943
+ function sectionKey(heading) {
944
+ return OBJECT_KEYS[heading] ?? snake(heading);
945
+ }
946
+ function splitSection(lines) {
947
+ const head = [], rows = [];
948
+ let pending = [];
949
+ let table2 = null, group = null;
950
+ let inTable = false, afterRow = false, fence = null;
951
+ const other = (line2, kind = "text") => {
952
+ if (rows.length) pending.push({ line: line2, kind });
953
+ else head.push(line2);
954
+ };
955
+ const addRow = (first) => {
956
+ if (rows.length) {
957
+ const prev = rows[rows.length - 1];
958
+ for (const p of pending) {
959
+ if (p.kind === "group" && p.line === group) continue;
960
+ if (p.kind === "table" && table2 && (p.line === table2[0] || p.line === table2[1])) continue;
961
+ if (!blank(p.line)) prev.lines.push(p.line);
962
+ }
963
+ }
964
+ pending = [];
965
+ rows.push({ lines: [first], table: table2, group });
966
+ afterRow = true;
967
+ };
968
+ for (let i = 0; i < lines.length; i++) {
969
+ const l = lines[i];
970
+ if (fence) {
971
+ if (fence === "row") rows[rows.length - 1].lines.push(l);
972
+ else other(l);
973
+ if (FENCE.test(l)) fence = null;
974
+ continue;
975
+ }
976
+ if (FENCE.test(l)) {
977
+ if (afterRow && /^ {2,}/.test(l)) {
978
+ rows[rows.length - 1].lines.push(l);
979
+ fence = "row";
980
+ } else {
981
+ other(l);
982
+ fence = "text";
983
+ afterRow = false;
984
+ inTable = false;
985
+ table2 = null;
986
+ }
987
+ continue;
988
+ }
989
+ if (afterRow && /^ {2,}\S/.test(l)) {
990
+ rows[rows.length - 1].lines.push(l);
991
+ continue;
992
+ }
993
+ if (l.startsWith("|")) {
994
+ if (!inTable && i + 1 < lines.length && SEPARATOR.test(lines[i + 1])) {
995
+ table2 = [l, lines[i + 1]];
996
+ inTable = true;
997
+ afterRow = false;
998
+ other(l, "table");
999
+ other(lines[++i], "table");
1000
+ continue;
1001
+ }
1002
+ if (inTable) {
1003
+ addRow(l);
1004
+ continue;
1005
+ }
1006
+ } else if (inTable) {
1007
+ inTable = false;
1008
+ table2 = null;
1009
+ }
1010
+ if (l.startsWith("- ") && !CAP_NOTE.test(l)) {
1011
+ addRow(l);
1012
+ continue;
1013
+ }
1014
+ afterRow = false;
1015
+ if (l.startsWith("### ")) {
1016
+ group = l;
1017
+ other(l, "group");
1018
+ continue;
1019
+ }
1020
+ other(l);
1021
+ }
1022
+ const tail = pending.map((p) => p.line);
1023
+ return { head, rows, tail };
1024
+ }
1025
+ var memo = null;
1026
+ function parsePage(md) {
1027
+ if (memo && memo.md === md) return memo.page;
1028
+ const all = md.split("\n");
1029
+ let i = 0, frontmatter = "";
1030
+ if (all[0] === "---") {
1031
+ const end = all.indexOf("---", 1);
1032
+ if (end > 0) {
1033
+ frontmatter = all.slice(1, end).join("\n");
1034
+ i = end + 1;
1035
+ }
1036
+ }
1037
+ const pre = [], chunks = [];
1038
+ let fence = false;
1039
+ for (; i < all.length; i++) {
1040
+ const l = all[i];
1041
+ if (!fence && l.startsWith("## ")) {
1042
+ chunks.push({ heading: l.slice(3).trim(), lines: [] });
1043
+ continue;
1044
+ }
1045
+ if (FENCE.test(l)) fence = !fence;
1046
+ (chunks.length ? chunks[chunks.length - 1].lines : pre).push(l);
1047
+ }
1048
+ const seen = /* @__PURE__ */ new Map();
1049
+ const sections = chunks.map(({ heading, lines }) => {
1050
+ while (lines.length && blank(lines[lines.length - 1])) lines.pop();
1051
+ const base = sectionKey(heading), n = (seen.get(base) ?? 0) + 1;
1052
+ seen.set(base, n);
1053
+ return { heading, key: n === 1 ? base : `${base}_${n}`, lines, ...splitSection(lines) };
1054
+ });
1055
+ const preamble = pre.join("\n").replace(/^\s*\n/, "").replace(/\s+$/, "");
1056
+ const page = { frontmatter, preamble, sections };
1057
+ memo = { md, page };
1058
+ return page;
1059
+ }
1060
+ function frontmatterSection(p) {
1061
+ const lines = p.frontmatter ? p.frontmatter.split("\n") : [];
1062
+ return { heading: "", key: "frontmatter", lines, head: [], rows: lines.map((l) => ({ lines: [l], table: null, group: null })), tail: [] };
1063
+ }
1064
+ function findSection(p, want) {
1065
+ const w = want.trim().toLowerCase(), ws = snake(want);
1066
+ if (w === "frontmatter") return frontmatterSection(p);
1067
+ 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;
1068
+ }
1069
+ var keyList = (p, counts = false) => [...p.sections.map((s) => counts && s.rows.length ? `${s.key} (${s.rows.length})` : s.key), "frontmatter"].join(", ");
1070
+ var titleOf = (p, path) => p.preamble.split("\n").find((l) => l.startsWith("# "))?.slice(2).trim() || path;
1071
+ function clip(s, n, mark = "\u2026") {
1072
+ if (s.length <= n) return s;
1073
+ const end = /[\uD800-\uDBFF]/.test(s[n - 2]) ? n - 2 : n - 1;
1074
+ return `${s.slice(0, end)}${mark}`;
1075
+ }
1076
+ var indentOf = (l) => l.length - l.trimStart().length;
1077
+ var BLOCK_SCALAR = /(?:^|:|-)\s*[|>][-+0-9]*\s*$/;
1078
+ var LIST_ITEM = /^( *)-(?: |$)/;
1079
+ function cutLists(lines) {
1080
+ const out = [];
1081
+ let i = 0;
1082
+ while (i < lines.length) {
1083
+ const l = lines[i], m = LIST_ITEM.exec(l);
1084
+ if (!m) {
1085
+ out.push(l);
1086
+ i++;
1087
+ if (BLOCK_SCALAR.test(l)) {
1088
+ const ind2 = indentOf(l);
1089
+ while (i < lines.length && (blank(lines[i]) || indentOf(lines[i]) > ind2)) out.push(lines[i++]);
1090
+ }
1091
+ continue;
1092
+ }
1093
+ const ind = m[1].length, items = [];
1094
+ while (i < lines.length && indentOf(lines[i]) === ind && LIST_ITEM.test(lines[i])) {
1095
+ const item = [lines[i++]];
1096
+ while (i < lines.length && (indentOf(lines[i]) > ind || blank(lines[i]) && i + 1 < lines.length && indentOf(lines[i + 1]) > ind)) item.push(lines[i++]);
1097
+ items.push(BLOCK_SCALAR.test(item[0]) ? item : [item[0], ...cutLists(item.slice(1))]);
1098
+ }
1099
+ const size = (k2) => items.slice(0, k2).map((it) => it.join("\n")).join("\n").length;
1100
+ if (size(items.length) <= FM_LIST_CHARS) {
1101
+ out.push(...items.flat());
1102
+ continue;
1103
+ }
1104
+ let k = 0;
1105
+ while (k < items.length && size(k + 1) <= FM_LIST_CHARS) k++;
1106
+ k = Math.max(k, FM_LIST_MIN);
1107
+ out.push(...items.slice(0, k).flat());
1108
+ if (k < items.length) out.push(`${" ".repeat(ind)}# \u2026 ${items.length - k} more; section: "frontmatter"`);
1109
+ }
1110
+ return out;
1111
+ }
1112
+ function compactFrontmatter(yaml) {
1113
+ const lines = yaml.split("\n"), kept = [];
1114
+ for (let i = 0; i < lines.length; i++) {
1115
+ if (/^generated:/.test(lines[i])) {
1116
+ while (i + 1 < lines.length && (indentOf(lines[i + 1]) > 0 || blank(lines[i + 1]))) i++;
1117
+ continue;
1118
+ }
1119
+ kept.push(lines[i]);
1120
+ }
1121
+ return cutLists(kept).join("\n");
1122
+ }
1123
+ function compactView(md, path, budget) {
1124
+ const p = parsePage(md);
1125
+ const out = [];
1126
+ if (md.startsWith("---\n") && p.frontmatter !== "") out.push("---", compactFrontmatter(p.frontmatter), "---", "");
1127
+ if (p.preamble) out.push(p.preamble, "");
1128
+ 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.`, "");
1129
+ for (const s of p.sections) {
1130
+ out.push(`## ${s.heading}`);
1131
+ const more = `\u2026 (section: "${s.key}")`;
1132
+ if (!s.rows.length) {
1133
+ const text2 = s.head.filter((l) => !blank(l)).join("\n");
1134
+ if (text2) out.push(clip(text2, PROSE_CLIP, more));
1135
+ } else {
1136
+ const lead = s.head.find((l) => !blank(l) && !l.startsWith("|") && !l.startsWith("### "));
1137
+ if (lead && lead.length <= HEAD_LINE_MAX) out.push(lead);
1138
+ let group = null, table2 = null;
1139
+ for (const r of s.rows.slice(0, PREVIEW_ROWS)) {
1140
+ if (r.group && r.group !== group) {
1141
+ out.push(r.group);
1142
+ group = r.group;
1143
+ }
1144
+ const t = r.table ? r.table.join("\n") : null;
1145
+ if (r.table && t !== table2) out.push(...r.table);
1146
+ table2 = t;
1147
+ out.push(clip(r.lines[0], ROW_CLIP));
1148
+ }
1149
+ 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
+ else {
1151
+ const text2 = s.tail.filter((l) => !blank(l)).join("\n");
1152
+ if (text2) out.push(clip(text2, PROSE_CLIP, more));
1153
+ }
1154
+ }
1155
+ out.push("");
1156
+ }
1157
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "")}
1158
+ `;
1159
+ }
1160
+ var dropLeadingBlank = (lines) => {
1161
+ let i = 0;
1162
+ while (i < lines.length && blank(lines[i])) i++;
1163
+ return lines.slice(i);
1164
+ };
1165
+ function sectionView(md, path, want, offset, limit, budget, site) {
1166
+ const p = parsePage(md);
1167
+ const nl = (lines) => `${lines.join("\n")}
1168
+ `;
1169
+ const s = findSection(p, want);
1170
+ if (!s) return `No section "${want}" on ${path}. Sections: ${keyList(p)}.`;
1171
+ const fm = s.key === "frontmatter" && s.heading === "";
1172
+ const first = `${titleOf(p, path)}, section "${s.key}": ${site}${path}/`;
1173
+ const heading = fm ? [] : [`## ${s.heading}`];
1174
+ const n = s.rows.length, off = Math.max(0, Math.floor(offset ?? 0));
1175
+ const verbatim = nl(fm ? [first, "", "---", ...s.lines, "---"] : [first, "", ...heading, ...s.lines]);
1176
+ if (!n || off === 0 && limit === void 0 && verbatim.length <= budget) return verbatim;
1177
+ const tail = dropLeadingBlank(s.tail);
1178
+ if (off >= n) return nl([first, "", ...heading, "", ...tail.length ? tail : [`No rows after ${n} in section "${s.key}" of ${path}.`]]);
1179
+ const out = [first, "", ...heading, ...fm ? [] : [""]];
1180
+ let group = null, table2 = null;
1181
+ if (off === 0) {
1182
+ out.push(...dropLeadingBlank(s.head));
1183
+ group = s.rows[0].group;
1184
+ table2 = s.rows[0].table ? s.rows[0].table.join("\n") : null;
1185
+ }
1186
+ let len = out.join("\n").length, b = off;
1187
+ const end = limit === void 0 ? n : Math.min(n, off + Math.max(1, Math.floor(limit)));
1188
+ for (let r = off; r < end; r++) {
1189
+ const row = s.rows[r], add = [];
1190
+ let g = group, t = table2;
1191
+ if (row.group && row.group !== g) {
1192
+ add.push(...out.length && !blank(out[out.length - 1]) ? [""] : [], row.group, "");
1193
+ g = row.group;
1194
+ t = null;
1195
+ }
1196
+ const rt = row.table ? row.table.join("\n") : null;
1197
+ if (row.table && rt !== t) add.push(...row.table);
1198
+ t = rt;
1199
+ add.push(...row.lines);
1200
+ const size = add.join("\n").length + 1;
1201
+ if (r > off && len + size > budget - RESERVE) break;
1202
+ out.push(...add);
1203
+ len += size;
1204
+ group = g;
1205
+ table2 = t;
1206
+ b = r + 1;
1207
+ }
1208
+ const rows = `Rows ${off + 1}-${b} of ${n}.`;
1209
+ if (b < n) return nl([...out, "", `${rows} Next: cat("${path}", section: "${s.key}", offset: ${b})`]);
1210
+ if (!tail.length) return nl([...out, "", rows]);
1211
+ const withTail = nl([...out, "", ...tail, "", rows]);
1212
+ if (limit === void 0 && withTail.length <= budget) return withTail;
1213
+ return nl([...out, "", `${rows} The section's closing text: cat("${path}", section: "${s.key}", offset: ${n})`]);
1214
+ }
1215
+ var ANCHOR_SECTION = { field: ["fields", "field"], value: ["values", "value"], event: ["events", "event"], proc: ["procedures", "procedure"] };
1216
+ function anchorView(md, path, anchor, site) {
1217
+ const a = anchor.replace(/^#/, ""), m = /^(field|value|event|proc)-(.+)$/.exec(a);
1218
+ if (!m) return null;
1219
+ const [key, label] = ANCHOR_SECTION[m[1]];
1220
+ const p = parsePage(md);
1221
+ const s = p.sections.find((x) => x.key === key);
1222
+ if (!s) return null;
1223
+ const match = m[1] === "field" || m[1] === "value" ? (l) => l.startsWith(`| ${m[2]} |`) : (l) => l.startsWith(`- \`${m[2]}(`);
1224
+ const rows = s.rows.filter((r) => match(r.lines[0]));
1225
+ if (!rows.length) return null;
1226
+ return `${[`${titleOf(p, path)}, ${label} ${m[2]}: ${site}${path}/#${a}`, "", `## ${s.heading}`, ...rows[0].table ?? [], ...rows.flatMap((r) => r.lines)].join("\n")}
1227
+ `;
1228
+ }
1229
+ function pageView(md, path, a, budget, site) {
1230
+ const hash = path.indexOf("#");
1231
+ if (hash >= 0) {
1232
+ const clean = path.slice(0, hash), anchor = path.slice(hash);
1233
+ return anchorView(md, clean, anchor, site) ?? `(No row for ${anchor} on this page.)
1234
+
1235
+ ${pageView(md, clean, {}, budget, site)}`;
1236
+ }
1237
+ if (a.section !== void 0 && a.section.trim() !== "") return sectionView(md, path, a.section, a.offset, a.limit, budget, site);
1238
+ if (a.offset !== void 0 || a.limit !== void 0) {
1239
+ return `offset and limit read one section: cat("${path}", section: "<key>", offset: ${a.offset ?? 0}). Sections: ${keyList(parsePage(md))}.`;
1240
+ }
1241
+ if (md.length <= budget) return md;
1242
+ return compactView(md, path, budget);
1243
+ }
1244
+
1245
+ // src/server.ts
1246
+ var VERSION = "0.4.0";
1247
+ var SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
1248
+ var LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
1249
+ var BUDGET = budgetOf(process.env.BC_OBSERVATORY_MAX_CHARS);
1250
+ var B = (Number.isFinite(BUDGET) ? BUDGET : DEFAULT_BUDGET).toLocaleString("en-US");
1251
+ var REPO = "https://github.com/waldo1001/waldo.BCObservatory";
1252
+ var DAY = 864e5;
1253
+ var cacheDir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", createHash("sha256").update(SITE).digest("hex").slice(0, 12));
1254
+ async function fetchText(url2) {
1255
+ const res = await fetch(url2, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
1256
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url2}`);
1257
+ return res.text();
1258
+ }
41
1259
  async function dataFile(rel) {
42
- if (LOCAL)
43
- return readFileSync(join(LOCAL, "data", rel), "utf8");
44
- return fetchText(`${SITE}${rel}`);
1260
+ if (LOCAL) return readFileSync(join(LOCAL, "data", rel), "utf8");
1261
+ return fetchText(`${SITE}${rel}`);
45
1262
  }
46
- /** A page's markdown: content/<path>.md locally, the site's markdown twin otherwise. */
47
1263
  async function pageMarkdown(path) {
48
- const clean = path.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
49
- if (LOCAL) {
50
- const p = join(LOCAL, "content", `${clean}.md`);
51
- if (!existsSync(p))
52
- throw new Error(`no page at ${clean}`);
53
- return readFileSync(p, "utf8");
54
- }
55
- return fetchText(`${SITE}${clean}.md`);
56
- }
57
- let pages = [];
58
- let search = null;
59
- let loadedAt = 0;
60
- /** Index shards by sha256: cached on disk, downloaded only when the manifest names a new hash. */
1264
+ const clean = path.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
1265
+ if (LOCAL) {
1266
+ const p = join(LOCAL, "content", `${clean}.md`);
1267
+ if (!existsSync(p)) throw new Error(`no page at ${clean}`);
1268
+ return readFileSync(p, "utf8");
1269
+ }
1270
+ return fetchText(`${SITE}${clean}.md`);
1271
+ }
1272
+ var pages = [];
1273
+ var index = null;
1274
+ var loadedAt = 0;
1275
+ async function shard(file, sha) {
1276
+ const cached = join(cacheDir, `${sha}.json`);
1277
+ if (!LOCAL && existsSync(cached)) return readFileSync(cached, "utf8");
1278
+ const text2 = await dataFile(`index/${file}`);
1279
+ if (!LOCAL) writeFileSync(cached, text2);
1280
+ return text2;
1281
+ }
61
1282
  async function loadIndex() {
62
- if (search && Date.now() - loadedAt < DAY)
63
- return;
64
- const manifest = JSON.parse(await dataFile("index/index-manifest.json"));
65
- const all = [];
66
- if (!LOCAL)
67
- mkdirSync(cacheDir, { recursive: true });
68
- for (const s of manifest.shards) {
69
- const cached = join(cacheDir, `${s.sha256}.json`);
70
- let text;
71
- if (!LOCAL && existsSync(cached))
72
- text = readFileSync(cached, "utf8");
73
- else {
74
- text = await dataFile(`index/${s.file}`);
75
- if (!LOCAL)
76
- writeFileSync(cached, text);
77
- }
78
- all.push(...JSON.parse(text));
1283
+ if (index && Date.now() - loadedAt < DAY) return;
1284
+ const manifest = JSON.parse(await dataFile("index/index-manifest.json"));
1285
+ const all = [];
1286
+ if (!LOCAL) mkdirSync(cacheDir, { recursive: true });
1287
+ for (const s of manifest.shards) all.push(...JSON.parse(await shard(s.file, s.sha256)));
1288
+ pages = all;
1289
+ const idx = prepare(all.map(pageToRecord));
1290
+ let sm = null;
1291
+ try {
1292
+ sm = JSON.parse(await dataFile("index/symbols-manifest.json"));
1293
+ } catch {
1294
+ sm = null;
1295
+ }
1296
+ if (sm) {
1297
+ const byPath = new Map(idx.records.map((r) => [r.id, r]));
1298
+ for (const [k, e] of Object.entries(sm.kinds)) {
1299
+ const files = e.files ?? (e.file ? [e.file] : []);
1300
+ for (const f of files) {
1301
+ const doc = JSON.parse(await shard(f, files.length === 1 ? e.sha256 : `${e.sha256}-${f}`));
1302
+ prepare(symbolRecords(KIND_OF[k], doc.rows, (pk) => byPath.get(`objects/${pk}`)), idx);
1303
+ }
79
1304
  }
80
- const ms = new MiniSearch({
81
- idField: "path", fields: ["title", "summary", "tagText", "path"],
82
- storeFields: ["path", "type", "title", "summary", "tier", "system", "date", "object_type", "object_id", "app", "country", "source", "status", "obsolete"],
83
- extractField: (doc, f) => (f === "tagText" ? (doc.tags ?? []).join(" ") : doc[f]),
84
- searchOptions: { boost: { title: 3, tagText: 1.5 }, prefix: true, fuzzy: 0.15 },
85
- });
86
- ms.addAll(all);
87
- pages = all;
88
- search = ms;
89
- loadedAt = Date.now();
90
- }
91
- const url = (path) => `${SITE}${path}/`;
92
- const line = (r) => `- ${r.title} [${r.type}${r.tier ? `, ${r.tier}` : ""}${r.date ? `, ${r.date}` : ""}] path=${r.path}\n ${r.summary}`;
93
- const text = (t) => ({ content: [{ type: "text", text: t }] });
94
- // ---------------------------------------------------------------------------------------------- embeddings (D63)
95
- const MODEL = {
96
- repo: "minishlab/potion-base-8M", revision: "bf8b056651a2c21b8d2565580b8569da283cab23",
97
- sha256: {
98
- "config.json": "2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553",
99
- "tokenizer.json": "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50",
100
- "model.safetensors": "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2",
101
- },
1305
+ }
1306
+ index = idx;
1307
+ loadedAt = Date.now();
1308
+ }
1309
+ var url = (path) => `${SITE}${path}/`;
1310
+ 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
+ in: ${r.path_label}` : ""}${r.stats ? `
1312
+ ${r.stats}` : ""}
1313
+ ${r.summary}`;
1314
+ var text = (t) => ({ content: [{ type: "text", text: t }] });
1315
+ var symbolHit = (r, tag = "") => `- ${r.name} (${symbolLine(r).replace(/ · /g, ", ")}) path=${r.id}${tag}${r.text ? `
1316
+ ${r.text}` : ""}`;
1317
+ var hitLine = (r, tag = "") => r.kind === "page" && r.page ? line(r.page, tag) : symbolHit(r, tag);
1318
+ var MODEL = {
1319
+ repo: "minishlab/potion-base-8M",
1320
+ revision: "bf8b056651a2c21b8d2565580b8569da283cab23",
1321
+ sha256: {
1322
+ "config.json": "2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553",
1323
+ "tokenizer.json": "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50",
1324
+ "model.safetensors": "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2"
1325
+ }
102
1326
  };
103
- const MODEL_DIR = process.env.BC_OBSERVATORY_MODEL_DIR ? resolve(process.env.BC_OBSERVATORY_MODEL_DIR) : null;
104
- // a local checkout is for offline use and tests: no download there unless a model directory is named
105
- const EMBEDDINGS = process.env.BC_OBSERVATORY_EMBEDDINGS !== "0" && (!LOCAL || !!MODEL_DIR);
106
- let model = null;
107
- let modelError = EMBEDDINGS ? null : "turned off";
108
- let vectors = null;
1327
+ var MODEL_DIR = process.env.BC_OBSERVATORY_MODEL_DIR ? resolve(process.env.BC_OBSERVATORY_MODEL_DIR) : null;
1328
+ var EMBEDDINGS = process.env.BC_OBSERVATORY_EMBEDDINGS !== "0" && (!LOCAL || !!MODEL_DIR);
1329
+ var model = null;
1330
+ var modelError = EMBEDDINGS ? null : "turned off";
1331
+ var vectors = null;
109
1332
  async function loadModel() {
110
- if (model || modelError)
111
- return model;
112
- try {
113
- let dir = MODEL_DIR;
114
- if (!dir) {
115
- dir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", "models", `potion-base-8M@${MODEL.revision.slice(0, 12)}`);
116
- mkdirSync(dir, { recursive: true });
117
- for (const [file, sha] of Object.entries(MODEL.sha256)) {
118
- const p = join(dir, file);
119
- if (existsSync(p))
120
- continue;
121
- const res = await fetch(`https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${file}`, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
122
- if (!res.ok)
123
- throw new Error(`HTTP ${res.status} for ${file}`);
124
- const buf = Buffer.from(await res.arrayBuffer());
125
- if (createHash("sha256").update(buf).digest("hex") !== sha)
126
- throw new Error(`${file} does not match its pinned SHA-256`);
127
- writeFileSync(p, buf);
128
- }
129
- }
130
- model = parseModel(readFileSync(join(dir, "tokenizer.json"), "utf8"), new Uint8Array(readFileSync(join(dir, "model.safetensors"))), JSON.parse(readFileSync(join(dir, "config.json"), "utf8")));
1333
+ if (model || modelError) return model;
1334
+ try {
1335
+ let dir = MODEL_DIR;
1336
+ if (!dir) {
1337
+ dir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", "models", `potion-base-8M@${MODEL.revision.slice(0, 12)}`);
1338
+ mkdirSync(dir, { recursive: true });
1339
+ for (const [file, sha] of Object.entries(MODEL.sha256)) {
1340
+ const p = join(dir, file);
1341
+ if (existsSync(p)) continue;
1342
+ const res = await fetch(`https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${file}`, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
1343
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${file}`);
1344
+ const buf = Buffer.from(await res.arrayBuffer());
1345
+ if (createHash("sha256").update(buf).digest("hex") !== sha) throw new Error(`${file} does not match its pinned SHA-256`);
1346
+ writeFileSync(p, buf);
1347
+ }
131
1348
  }
132
- catch (e) {
133
- modelError = String(e.message).slice(0, 160);
134
- }
135
- return model;
1349
+ model = parseModel(readFileSync(join(dir, "tokenizer.json"), "utf8"), new Uint8Array(readFileSync(join(dir, "model.safetensors"))), JSON.parse(readFileSync(join(dir, "config.json"), "utf8")));
1350
+ } catch (e) {
1351
+ modelError = String(e.message).slice(0, 160);
1352
+ }
1353
+ return model;
136
1354
  }
137
- /** One vector per page (title, summary, tags), rebuilt when the index reloads: 22k pages take about 0.3 s. */
138
1355
  function pageVectors(m) {
139
- if (vectors && vectors.at === loadedAt)
140
- return vectors.data;
141
- const data = new Float32Array(pages.length * m.dims);
142
- pages.forEach((p, i) => data.set(embed(m, [p.title, p.summary, ...(p.tags ?? [])].filter(Boolean).join(". ")), i * m.dims));
143
- vectors = { at: loadedAt, data };
144
- return data;
145
- }
146
- /** Reciprocal rank fusion of ranked lists of paths (k = 60). */
147
- export function fuse(lists, k = 60) {
148
- const score = new Map();
149
- for (const list of lists)
150
- list.forEach((p, i) => score.set(p, (score.get(p) ?? 0) + 1 / (k + i + 1)));
151
- return [...score].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([p]) => p);
152
- }
153
- // ---------------------------------------------------------------------------------------------- tools
154
- export async function toolSearch(a) {
155
- await loadIndex();
156
- const keep = (r) => (!a.type || r.type === a.type) && (!a.tier || r.tier === a.tier) && (!a.system || r.system === a.system);
157
- const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
158
- const keyword = mode === "semantic" ? [] : search.search(a.query, { filter: (r) => keep(r) }).slice(0, POOL).map((r) => r.path);
159
- let semantic = [], note = "";
160
- if (mode !== "keyword") {
161
- const m = await loadModel();
162
- if (m) {
163
- const q = embed(m, a.query), data = pageVectors(m);
164
- if (q.some((x) => x !== 0)) {
165
- const scored = [];
166
- pages.forEach((p, i) => { if (keep(p))
167
- scored.push([dot(q, data, i * m.dims), i]); });
168
- semantic = scored.sort((x, y) => y[0] - x[0]).slice(0, POOL).map(([, i]) => pages[i].path);
169
- }
170
- }
171
- else if (mode === "semantic")
172
- note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
173
- if (!m && mode === "semantic")
174
- semantic = search.search(a.query, { filter: (r) => keep(r) }).slice(0, POOL).map((r) => r.path);
175
- }
176
- const byPath = new Map(pages.map((p) => [p.path, p]));
177
- const hits = fuse([keyword, semantic].filter((l) => l.length)).slice(0, limit).map((p) => byPath.get(p)).filter(Boolean);
178
- if (!hits.length)
179
- return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.`;
180
- const how = semantic.length && keyword.length ? "keyword + semantic" : semantic.length ? "semantic" : "keyword";
181
- return `${hits.length} results, ${how}${note} (read one with cat(path)):\n${hits.map(line).join("\n")}`;
182
- }
183
- export async function toolLs(a) {
184
- await loadIndex();
185
- const prefix = (a.path ?? "").replace(/^\/+|\/+$/g, "");
186
- const under = pages.filter((p) => !prefix || p.path === prefix || p.path.startsWith(`${prefix}/`));
187
- if (!under.length)
188
- return `Nothing under "${prefix}". Sections: ${[...new Set(pages.map((p) => p.path.split("/")[0]))].sort().join(", ")}.`;
189
- const depth = prefix ? prefix.split("/").length : 0;
190
- const dirs = new Map(), files = [];
191
- for (const p of under) {
192
- const parts = p.path.split("/");
193
- if (parts.length === depth + 1)
194
- files.push(p);
195
- else if (parts.length > depth + 1)
196
- dirs.set(parts.slice(0, depth + 1).join("/"), (dirs.get(parts.slice(0, depth + 1).join("/")) ?? 0) + 1);
197
- }
198
- const MAX = 200;
199
- const out = [...[...dirs].sort().map(([d, n]) => `${d}/ (${n} pages)`), ...files.slice(0, MAX).map((f) => `${f.path}: ${f.title}`)];
200
- return `${out.join("\n")}${files.length > MAX ? `\n... and ${files.length - MAX} more pages; use search() to narrow down` : ""}`;
201
- }
202
- export async function toolCat(a) {
203
- return pageMarkdown(a.path);
204
- }
205
- export async function toolGetObject(a) {
206
- await loadIndex();
207
- const type = a.type.toLowerCase().replace(/\s+/g, "");
208
- const want = a.idOrName.trim().replace(/^"|"$/g, "");
209
- const objs = pages.filter((p) => p.type === "object" && p.object_type === type);
210
- const hit = /^\d+$/.test(want) ? objs.find((p) => p.object_id === Number(want))
211
- : objs.find((p) => p.title.toLowerCase().endsWith(`"${want.toLowerCase()}"`));
212
- if (!hit)
213
- return `No ${type} "${want}" in W1 or Microsoft's first-party apps (BC28-30). Never invent object ids; country-only objects are listed on localization pages.`;
214
- return pageMarkdown(hit.path);
215
- }
216
- export async function toolDiffObject(a) {
217
- const key = `${a.type.toLowerCase()}/${a.id.toLowerCase()}`;
218
- let diff;
219
- try {
220
- diff = JSON.parse(await dataFile(`code/diffs/version/${a.from}__${a.to}.json`));
221
- }
222
- catch {
223
- return `No version diff ${a.from} -> ${a.to}. Diffs exist between consecutive majors from BC23 to BC30 (23 -> 24, ..., 29 -> 30); a changed member lists only what differs, as [from, to].`;
224
- }
225
- const o = diff.objects.find((x) => x.key === key);
226
- if (!o)
227
- return `${key} did not change between BC${a.from} and BC${a.to} (or is not a W1 object).`;
228
- return `${key} "${o.name}" BC${a.from} -> BC${a.to}: ${o.change}\n${JSON.stringify(o, null, 1)}`;
229
- }
230
- export async function toolLocalization(a) {
231
- return pageMarkdown(`localizations/${a.country.toLowerCase()}`);
232
- }
233
- export async function toolWhatsNew(a) {
234
- await loadIndex();
235
- const since = a.since.slice(0, 10);
236
- const items = pages.filter((p) => p.date && p.date >= since && (!a.type || p.type === a.type)).sort((x, y) => y.date.localeCompare(x.date)).slice(0, a.limit ?? 30);
237
- if (!items.length)
238
- return `Nothing dated on or after ${since}.`;
239
- return `${items.length} items since ${since}, newest first:\n${items.map(line).join("\n")}`;
240
- }
241
- export async function toolBlogFootprint(a) {
242
- await loadIndex();
243
- const items = pages.filter((p) => p.source === a.source).sort((x, y) => (y.date ?? "").localeCompare(x.date ?? ""));
244
- if (!items.length)
245
- return `No posts or videos from source "${a.source}" yet. Source ids are listed in ${REPO}/blob/main/sources.yaml.`;
246
- const systems = new Map();
247
- for (const p of items)
248
- if (p.system)
249
- systems.set(p.system, (systems.get(p.system) ?? 0) + 1);
250
- return `${a.source}: ${items.length} items, ${items.at(-1).date ?? "?"} to ${items[0].date ?? "?"}. Systems: ${[...systems].sort((x, y) => y[1] - x[1]).map(([s, n]) => `${s} ${n}`).join(", ")}.\n${items.slice(0, 20).map(line).join("\n")}`;
251
- }
252
- export function toolFeedback(a) {
253
- const title = `Feedback on ${a.path}`;
254
- const body = `Page: ${url(a.path)}\n\n${a.message}\n\n(sent from the bc-observatory MCP server ${VERSION})`;
255
- 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`;
256
- }
257
- // ---------------------------------------------------------------------------------------------- server
258
- export function createServer() {
259
- const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
260
- 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). 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).",
261
- });
262
- server.registerTool("search", { title: "Search the knowledge base", description: "Search every page (title, summary, tags). Hybrid by default: keywords plus meaning, so a synonym or a paraphrase still finds the page; mode 'keyword' for exact terms such as an object name, 'semantic' for meaning only. Filter by type (topic, feature, object, localization, video, post), tier (official, community) or galaxy system (finance, sales, development, ...).",
263
- inputSchema: { query: z.string(), type: z.string().optional(), tier: z.string().optional(), system: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), mode: z.enum(["hybrid", "keyword", "semantic"]).optional() } }, async (a) => text(await toolSearch(a)));
264
- server.registerTool("ls", { title: "List pages", description: "Browse the page tree, e.g. ls('objects/table') or ls('localizations'); empty path lists the sections.", inputSchema: { path: z.string().optional() } }, async (a) => text(await toolLs(a)));
265
- server.registerTool("cat", { 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() } }, async (a) => text(await toolCat(a)));
266
- server.registerTool("get_object", { title: "Look up an AL object", description: "An AL object of W1 or a Microsoft first-party app by type and id or exact name, e.g. ('table', '18') or ('codeunit', 'Sales-Post'): fields, keys, events, public procedures, obsolete state, versions, countries that replace it, Learn pages.",
267
- inputSchema: { type: z.string(), idOrName: z.string() } }, async (a) => text(await toolGetObject(a)));
268
- server.registerTool("diff_object", { title: "What changed in an object", description: "Member-level changes of a W1 object between two consecutive versions (fields, events, procedures, keys, properties, obsolete state).",
269
- inputSchema: { type: z.string(), id: z.string(), from: z.string(), to: z.string() } }, async (a) => text(await toolDiffObject(a)));
270
- server.registerTool("localization", { 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) } }, async (a) => text(await toolLocalization(a)));
271
- server.registerTool("whats_new", { title: "What is new", description: "Videos, posts and roadmap features dated on or after a date (YYYY-MM-DD), newest first.",
272
- inputSchema: { since: z.string(), type: z.string().optional(), limit: z.number().int().min(1).max(100).optional() } }, async (a) => text(await toolWhatsNew(a)));
273
- server.registerTool("blog_footprint", { title: "A source's footprint", description: "What a blog or channel (source id from sources.yaml, e.g. 'kauffmann-nl', 'yt-hougaard') covers: its posts or videos and the systems they touch.",
274
- inputSchema: { source: z.string() } }, async (a) => text(await toolBlogFootprint(a)));
275
- server.registerTool("feedback", { title: "Report a problem with a page", description: "Returns a prefilled GitHub issue link for a page (wrong fact, missing evidence, broken link). Nothing is sent by the agent.",
276
- inputSchema: { path: z.string(), message: z.string() } }, async (a) => text(toolFeedback(a)));
277
- return server;
278
- }
279
- /** Started as a program (npx, bin symlink, node dist/server.js, tsx src/server.ts) rather than imported by a test. */
280
- function isMain() {
281
- if (!process.argv[1])
282
- return false;
283
- try {
284
- return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
1356
+ if (vectors && vectors.at === loadedAt) return vectors.data;
1357
+ const data = new Float32Array(pages.length * m.dims);
1358
+ pages.forEach((p, i) => data.set(embed(m, [p.title, p.summary, ...p.tags ?? []].filter(Boolean).join(". ")), i * m.dims));
1359
+ vectors = { at: loadedAt, data };
1360
+ return data;
1361
+ }
1362
+ function fuse(lists, k = 60) {
1363
+ const score = /* @__PURE__ */ new Map();
1364
+ for (const list of lists) list.forEach((p, i) => score.set(p, (score.get(p) ?? 0) + 1 / (k + i + 1)));
1365
+ return [...score].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([p]) => p);
1366
+ }
1367
+ function keepOf(a) {
1368
+ 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);
1370
+ const system = a.system ? systemOf(a.system) ?? lc(a.system) : void 0;
1371
+ const objectType = a.object_type ? typeOfWord(a.object_type.replace(/\s+/g, "")) ?? lc(a.object_type) : void 0;
1372
+ 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);
1373
+ }
1374
+ async function toolSearch(a) {
1375
+ await loadIndex();
1376
+ const keep2 = keepOf(a);
1377
+ const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
1378
+ const q = parseQuery(a.query, { countries: countriesOf(index) });
1379
+ const hits = search(index, q, { filter: keep2 });
1380
+ const byId = new Map(hits.map((h) => [h.r.id, h.r]));
1381
+ let semantic = [], note = "";
1382
+ if (mode !== "keyword") {
1383
+ const m = await loadModel();
1384
+ if (m) {
1385
+ const v = embed(m, a.query), data = pageVectors(m);
1386
+ if (v.some((x) => x !== 0)) {
1387
+ const scored = [];
1388
+ index.records.forEach((r, i) => {
1389
+ if (r.kind === "page" && i < pages.length && keep2(r)) scored.push([dot(v, data, i * m.dims), i]);
1390
+ });
1391
+ semantic = scored.sort((x, y) => y[0] - x[0]).slice(0, POOL).map(([, i]) => {
1392
+ const r = index.records[i];
1393
+ byId.set(r.id, r);
1394
+ return r.id;
1395
+ });
1396
+ }
1397
+ } else if (mode === "semantic") note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
1398
+ }
1399
+ const keyword = hits.map((h) => h.r.id);
1400
+ let order, exactBand = 0;
1401
+ if (mode === "semantic") order = semantic.length ? semantic : keyword.slice(0, POOL);
1402
+ else if (mode === "keyword" || !semantic.length) order = keyword;
1403
+ else {
1404
+ const exact = hits.filter((h) => h.band === "exact").map((h) => h.r.id), pinned = new Set(exact);
1405
+ 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))])];
1407
+ }
1408
+ if (mode !== "semantic" && mode !== "keyword" && !semantic.length) exactBand = hits.filter((h) => h.band === "exact").length;
1409
+ const inKeyword = new Set(keyword.slice(0, Math.max(POOL, limit))), inMeaning = new Set(semantic);
1410
+ const tag = (id) => inKeyword.has(id) && inMeaning.has(id) ? " [both]" : inMeaning.has(id) ? " [meaning]" : " [keyword]";
1411
+ 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));
1413
+ const hintText = hs.length ? `
1414
+ ${hs.map((h) => `Hint: ${h.text}${h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : ""}`).join("\n")}` : "";
1415
+ if (!shown.length) return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.${note}${hintText}`;
1416
+ 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}`;
1419
+ }
1420
+ async function toolLs(a) {
1421
+ await loadIndex();
1422
+ const prefix = (a.path ?? "").replace(/^\/+|\/+$/g, "");
1423
+ const under = pages.filter((p) => !prefix || p.path === prefix || p.path.startsWith(`${prefix}/`));
1424
+ if (!under.length) return `Nothing under "${prefix}". Sections: ${[...new Set(pages.map((p) => p.path.split("/")[0]))].sort().join(", ")}.`;
1425
+ const depth = prefix ? prefix.split("/").length : 0;
1426
+ const dirs = /* @__PURE__ */ new Map(), files = [];
1427
+ for (const p of under) {
1428
+ const parts2 = p.path.split("/");
1429
+ if (parts2.length === depth + 1) files.push(p);
1430
+ else if (parts2.length > depth + 1) dirs.set(parts2.slice(0, depth + 1).join("/"), (dirs.get(parts2.slice(0, depth + 1).join("/")) ?? 0) + 1);
1431
+ }
1432
+ const MAX = 200;
1433
+ const out = [...[...dirs].sort().map(([d, n]) => `${d}/ (${n} pages)`), ...files.slice(0, MAX).map((f) => `${f.path}: ${f.title}`)];
1434
+ return `${out.join("\n")}${files.length > MAX ? `
1435
+ ... and ${files.length - MAX} more pages; use search() to narrow down` : ""}`;
1436
+ }
1437
+ async function toolCat(a) {
1438
+ const [base, anchor] = a.path.split(/(?=#)/);
1439
+ const clean = `${base.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "")}${anchor ?? ""}`;
1440
+ return pageView(await pageMarkdown(clean.replace(/#.*$/, "")), clean, a, BUDGET, SITE);
1441
+ }
1442
+ async function toolGetObject(a) {
1443
+ await loadIndex();
1444
+ const type = typeOfWord(a.type.toLowerCase().replace(/\s+/g, "")) ?? a.type.toLowerCase().replace(/\s+/g, "");
1445
+ let want = a.idOrName.trim().replace(/^"|"$/g, "");
1446
+ let country = a.country?.trim().toLowerCase() || null;
1447
+ const objs = index.records.filter((r) => r.kind === "page" && r.type === "object" && r.objectType === type);
1448
+ let id = null;
1449
+ const q = parseQuery(want, { countries: countriesOf(index) });
1450
+ if (q.ref && q.ref.type === type) {
1451
+ id = q.ref.id;
1452
+ country ??= q.ref.country;
1453
+ } else if (q.number !== null) id = q.number;
1454
+ else {
1455
+ const m = /^(\d+)-([a-z]{2})$/i.exec(want);
1456
+ if (m) {
1457
+ id = Number(m[1]);
1458
+ country ??= m[2].toLowerCase();
285
1459
  }
286
- catch {
287
- return false;
1460
+ }
1461
+ const cc = /^(.*?)\s*\(([A-Za-z]{2})\)$/.exec(want);
1462
+ if (id === null && cc) {
1463
+ want = cc[1];
1464
+ country ??= cc[2].toLowerCase();
1465
+ }
1466
+ const lw = want.toLowerCase();
1467
+ const tiers = id !== null ? [objs.filter((r) => r.objectId === id)] : [objs.filter((r) => r.name.toLowerCase() === lw), objs.filter((r) => (r.caption ?? "").toLowerCase() === lw)];
1468
+ for (const found of tiers) {
1469
+ if (!found.length) continue;
1470
+ const inCountry = country ? found.filter((r) => r.country === country) : [];
1471
+ const w1 = found.filter((r) => !r.country);
1472
+ const pick = country ? inCountry.length ? inCountry : w1 : w1.length ? w1 : found;
1473
+ if (pick.length === 1) {
1474
+ 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.)
1476
+
1477
+ ` : "";
1478
+ const also = !country && twins.length ? `
1479
+
1480
+ (Also in ${twins.join(", ")}: get_object("${type}", "${pick[0].objectId ?? pick[0].name}", country).)` : "";
1481
+ return `${note}${pageView(await pageMarkdown(pick[0].id), pick[0].id, a, BUDGET, SITE)}${a.section ? "" : also}`;
288
1482
  }
1483
+ if (pick.length > 1) return `${pick.length} ${type} objects match "${a.idOrName}"; read one with cat(path):
1484
+ ${pick.map((r) => `- ${r.title} path=${r.id}`).join("\n")}`;
1485
+ }
1486
+ return `No ${type} "${a.idOrName}" in W1, Microsoft's first-party apps or the country layers (BC28-30). Never invent object ids; search("${want}") lists near names.`;
1487
+ }
1488
+ async function toolDiffObject(a) {
1489
+ const key = `${a.type.toLowerCase()}/${a.id.toLowerCase()}`;
1490
+ let diff;
1491
+ try {
1492
+ diff = JSON.parse(await dataFile(`code/diffs/version/${a.from}__${a.to}.json`));
1493
+ } catch {
1494
+ return `No version diff ${a.from} -> ${a.to}. Diffs exist between consecutive majors from BC23 to BC30 (23 -> 24, ..., 29 -> 30); a changed member lists only what differs, as [from, to].`;
1495
+ }
1496
+ const o = diff.objects.find((x) => x.key === key);
1497
+ if (!o) return `${key} did not change between BC${a.from} and BC${a.to} (or is not a W1 object).`;
1498
+ return `${key} "${o.name}" BC${a.from} -> BC${a.to}: ${o.change}
1499
+ ${JSON.stringify(o, null, 1)}`;
1500
+ }
1501
+ async function toolLocalization(a) {
1502
+ const p = `localizations/${a.country.toLowerCase()}`;
1503
+ return pageView(await pageMarkdown(p), p, a, BUDGET, SITE);
1504
+ }
1505
+ async function toolWhatsNew(a, today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
1506
+ await loadIndex();
1507
+ const since = a.since.slice(0, 10);
1508
+ const dated = pages.filter((p) => p.date && p.date >= since && (!a.type || p.type === a.type));
1509
+ const future = dated.filter((p) => p.date > today);
1510
+ const items = (a.include_future ? dated : dated.filter((p) => p.date <= today)).sort((x, y) => y.date.localeCompare(x.date)).slice(0, a.limit ?? 30);
1511
+ const tail = !a.include_future && future.length ? `
1512
+ ${future.length} roadmap ${future.length === 1 ? "feature" : "features"} scheduled after today: pass include_future: true` : "";
1513
+ if (!items.length) return `Nothing dated on or after ${since}${a.include_future ? "" : ` up to today (${today})`}.${tail}`;
1514
+ return `${items.length} items since ${since}, newest first:
1515
+ ${items.map((p) => line(p)).join("\n")}${tail}`;
1516
+ }
1517
+ async function toolBlogFootprint(a) {
1518
+ await loadIndex();
1519
+ const items = pages.filter((p) => p.source === a.source).sort((x, y) => (y.date ?? "").localeCompare(x.date ?? ""));
1520
+ if (!items.length) return `No posts or videos from source "${a.source}" yet. Source ids are listed in ${REPO}/blob/main/sources.yaml.`;
1521
+ const systems = /* @__PURE__ */ new Map();
1522
+ for (const p of items) if (p.system) systems.set(p.system, (systems.get(p.system) ?? 0) + 1);
1523
+ return `${a.source}: ${items.length} items, ${items.at(-1).date ?? "?"} to ${items[0].date ?? "?"}. Systems: ${[...systems].sort((x, y) => y[1] - x[1]).map(([s, n]) => `${s} ${n}`).join(", ")}.
1524
+ ${items.slice(0, 20).map((p) => line(p)).join("\n")}`;
1525
+ }
1526
+ function toolFeedback(a) {
1527
+ const title = `Feedback on ${a.path}`;
1528
+ const body = `Page: ${url(a.path)}
1529
+
1530
+ ${a.message}
1531
+
1532
+ (sent from the bc-observatory MCP server ${VERSION})`;
1533
+ 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`;
1534
+ }
1535
+ 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
+ function createServer() {
1537
+ 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)."
1539
+ });
1540
+ server.registerTool(
1541
+ "search",
1542
+ {
1543
+ 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).",
1545
+ 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
+ },
1547
+ async (a) => text(await toolSearch(a))
1548
+ );
1549
+ server.registerTool(
1550
+ "ls",
1551
+ { title: "List pages", description: "Browse the page tree, e.g. ls('objects/table') or ls('localizations'); empty path lists the sections.", inputSchema: { path: z.string().optional() } },
1552
+ async (a) => text(await toolLs(a))
1553
+ );
1554
+ server.registerTool(
1555
+ "cat",
1556
+ { 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 } },
1557
+ async (a) => text(await toolCat(a))
1558
+ );
1559
+ server.registerTool("get_object", {
1560
+ 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.`,
1562
+ inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional(), ...VIEW }
1563
+ }, async (a) => text(await toolGetObject(a)));
1564
+ server.registerTool("diff_object", {
1565
+ title: "What changed in an object",
1566
+ description: "Member-level changes of a W1 object between two consecutive versions (fields, events, procedures, keys, properties, obsolete state).",
1567
+ inputSchema: { type: z.string(), id: z.string(), from: z.string(), to: z.string() }
1568
+ }, async (a) => text(await toolDiffObject(a)));
1569
+ server.registerTool(
1570
+ "localization",
1571
+ { 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 } },
1572
+ async (a) => text(await toolLocalization(a))
1573
+ );
1574
+ server.registerTool("whats_new", {
1575
+ title: "What is new",
1576
+ 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.",
1577
+ inputSchema: { since: z.string(), type: z.string().optional(), limit: z.number().int().min(1).max(100).optional(), include_future: z.boolean().optional() }
1578
+ }, async (a) => text(await toolWhatsNew(a)));
1579
+ server.registerTool("blog_footprint", {
1580
+ title: "A source's footprint",
1581
+ description: "What a blog or channel (source id from sources.yaml, e.g. 'kauffmann-nl', 'yt-hougaard') covers: its posts or videos and the systems they touch.",
1582
+ inputSchema: { source: z.string() }
1583
+ }, async (a) => text(await toolBlogFootprint(a)));
1584
+ server.registerTool("feedback", {
1585
+ title: "Report a problem with a page",
1586
+ description: "Returns a prefilled GitHub issue link for a page (wrong fact, missing evidence, broken link). Nothing is sent by the agent.",
1587
+ inputSchema: { path: z.string(), message: z.string() }
1588
+ }, async (a) => text(toolFeedback(a)));
1589
+ return server;
1590
+ }
1591
+ function isMain() {
1592
+ if (!process.argv[1]) return false;
1593
+ try {
1594
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
1595
+ } catch {
1596
+ return false;
1597
+ }
289
1598
  }
290
1599
  if (isMain()) {
291
- await createServer().connect(new StdioServerTransport());
1600
+ await createServer().connect(new StdioServerTransport());
292
1601
  }
1602
+ export {
1603
+ createServer,
1604
+ fuse,
1605
+ keepOf,
1606
+ toolBlogFootprint,
1607
+ toolCat,
1608
+ toolDiffObject,
1609
+ toolFeedback,
1610
+ toolGetObject,
1611
+ toolLocalization,
1612
+ toolLs,
1613
+ toolSearch,
1614
+ toolWhatsNew
1615
+ };