bc-observatory 0.2.0 → 0.3.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/README.md +18 -3
- package/dist/server.js +1231 -265
- package/package.json +6 -3
- package/dist/embed.js +0 -133
package/dist/server.js
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
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,1251 @@ 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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;
|
|
45
44
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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;
|
|
54
85
|
}
|
|
55
|
-
|
|
86
|
+
if (best2 > max) return max + 1;
|
|
87
|
+
[prev2, prev, cur] = [prev, cur, prev2];
|
|
88
|
+
}
|
|
89
|
+
return prev[m];
|
|
56
90
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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));
|
|
79
199
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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]);
|
|
102
389
|
};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
426
|
}
|
|
132
|
-
|
|
133
|
-
|
|
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));
|
|
134
432
|
}
|
|
135
|
-
|
|
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;
|
|
136
439
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}
|
|
175
486
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 });
|
|
197
510
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 } : {} };
|
|
221
536
|
}
|
|
222
|
-
|
|
223
|
-
|
|
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;
|
|
224
547
|
}
|
|
225
|
-
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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;
|
|
261
680
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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}` });
|
|
285
751
|
}
|
|
286
|
-
|
|
287
|
-
|
|
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/server.ts
|
|
895
|
+
var VERSION = "0.3.0";
|
|
896
|
+
var SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
|
|
897
|
+
var LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
|
|
898
|
+
var REPO = "https://github.com/waldo1001/waldo.BCObservatory";
|
|
899
|
+
var DAY = 864e5;
|
|
900
|
+
var cacheDir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", createHash("sha256").update(SITE).digest("hex").slice(0, 12));
|
|
901
|
+
async function fetchText(url2) {
|
|
902
|
+
const res = await fetch(url2, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
|
|
903
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url2}`);
|
|
904
|
+
return res.text();
|
|
905
|
+
}
|
|
906
|
+
async function dataFile(rel) {
|
|
907
|
+
if (LOCAL) return readFileSync(join(LOCAL, "data", rel), "utf8");
|
|
908
|
+
return fetchText(`${SITE}${rel}`);
|
|
909
|
+
}
|
|
910
|
+
async function pageMarkdown(path) {
|
|
911
|
+
const clean = path.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
|
|
912
|
+
if (LOCAL) {
|
|
913
|
+
const p = join(LOCAL, "content", `${clean}.md`);
|
|
914
|
+
if (!existsSync(p)) throw new Error(`no page at ${clean}`);
|
|
915
|
+
return readFileSync(p, "utf8");
|
|
916
|
+
}
|
|
917
|
+
return fetchText(`${SITE}${clean}.md`);
|
|
918
|
+
}
|
|
919
|
+
var pages = [];
|
|
920
|
+
var index = null;
|
|
921
|
+
var loadedAt = 0;
|
|
922
|
+
async function shard(file, sha) {
|
|
923
|
+
const cached = join(cacheDir, `${sha}.json`);
|
|
924
|
+
if (!LOCAL && existsSync(cached)) return readFileSync(cached, "utf8");
|
|
925
|
+
const text2 = await dataFile(`index/${file}`);
|
|
926
|
+
if (!LOCAL) writeFileSync(cached, text2);
|
|
927
|
+
return text2;
|
|
928
|
+
}
|
|
929
|
+
async function loadIndex() {
|
|
930
|
+
if (index && Date.now() - loadedAt < DAY) return;
|
|
931
|
+
const manifest = JSON.parse(await dataFile("index/index-manifest.json"));
|
|
932
|
+
const all = [];
|
|
933
|
+
if (!LOCAL) mkdirSync(cacheDir, { recursive: true });
|
|
934
|
+
for (const s of manifest.shards) all.push(...JSON.parse(await shard(s.file, s.sha256)));
|
|
935
|
+
pages = all;
|
|
936
|
+
const idx = prepare(all.map(pageToRecord));
|
|
937
|
+
let sm = null;
|
|
938
|
+
try {
|
|
939
|
+
sm = JSON.parse(await dataFile("index/symbols-manifest.json"));
|
|
940
|
+
} catch {
|
|
941
|
+
sm = null;
|
|
942
|
+
}
|
|
943
|
+
if (sm) {
|
|
944
|
+
const byPath = new Map(idx.records.map((r) => [r.id, r]));
|
|
945
|
+
for (const [k, e] of Object.entries(sm.kinds)) {
|
|
946
|
+
const files = e.files ?? (e.file ? [e.file] : []);
|
|
947
|
+
for (const f of files) {
|
|
948
|
+
const doc = JSON.parse(await shard(f, files.length === 1 ? e.sha256 : `${e.sha256}-${f}`));
|
|
949
|
+
prepare(symbolRecords(KIND_OF[k], doc.rows, (pk) => byPath.get(`objects/${pk}`)), idx);
|
|
950
|
+
}
|
|
288
951
|
}
|
|
952
|
+
}
|
|
953
|
+
index = idx;
|
|
954
|
+
loadedAt = Date.now();
|
|
955
|
+
}
|
|
956
|
+
var url = (path) => `${SITE}${path}/`;
|
|
957
|
+
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 ? `
|
|
958
|
+
in: ${r.path_label}` : ""}${r.stats ? `
|
|
959
|
+
${r.stats}` : ""}
|
|
960
|
+
${r.summary}`;
|
|
961
|
+
var text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
962
|
+
var symbolHit = (r, tag = "") => `- ${r.name} (${symbolLine(r).replace(/ · /g, ", ")}) path=${r.id}${tag}${r.text ? `
|
|
963
|
+
${r.text}` : ""}`;
|
|
964
|
+
var hitLine = (r, tag = "") => r.kind === "page" && r.page ? line(r.page, tag) : symbolHit(r, tag);
|
|
965
|
+
var MODEL = {
|
|
966
|
+
repo: "minishlab/potion-base-8M",
|
|
967
|
+
revision: "bf8b056651a2c21b8d2565580b8569da283cab23",
|
|
968
|
+
sha256: {
|
|
969
|
+
"config.json": "2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553",
|
|
970
|
+
"tokenizer.json": "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50",
|
|
971
|
+
"model.safetensors": "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2"
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
var MODEL_DIR = process.env.BC_OBSERVATORY_MODEL_DIR ? resolve(process.env.BC_OBSERVATORY_MODEL_DIR) : null;
|
|
975
|
+
var EMBEDDINGS = process.env.BC_OBSERVATORY_EMBEDDINGS !== "0" && (!LOCAL || !!MODEL_DIR);
|
|
976
|
+
var model = null;
|
|
977
|
+
var modelError = EMBEDDINGS ? null : "turned off";
|
|
978
|
+
var vectors = null;
|
|
979
|
+
async function loadModel() {
|
|
980
|
+
if (model || modelError) return model;
|
|
981
|
+
try {
|
|
982
|
+
let dir = MODEL_DIR;
|
|
983
|
+
if (!dir) {
|
|
984
|
+
dir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", "models", `potion-base-8M@${MODEL.revision.slice(0, 12)}`);
|
|
985
|
+
mkdirSync(dir, { recursive: true });
|
|
986
|
+
for (const [file, sha] of Object.entries(MODEL.sha256)) {
|
|
987
|
+
const p = join(dir, file);
|
|
988
|
+
if (existsSync(p)) continue;
|
|
989
|
+
const res = await fetch(`https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${file}`, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
|
|
990
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${file}`);
|
|
991
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
992
|
+
if (createHash("sha256").update(buf).digest("hex") !== sha) throw new Error(`${file} does not match its pinned SHA-256`);
|
|
993
|
+
writeFileSync(p, buf);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
model = parseModel(readFileSync(join(dir, "tokenizer.json"), "utf8"), new Uint8Array(readFileSync(join(dir, "model.safetensors"))), JSON.parse(readFileSync(join(dir, "config.json"), "utf8")));
|
|
997
|
+
} catch (e) {
|
|
998
|
+
modelError = String(e.message).slice(0, 160);
|
|
999
|
+
}
|
|
1000
|
+
return model;
|
|
1001
|
+
}
|
|
1002
|
+
function pageVectors(m) {
|
|
1003
|
+
if (vectors && vectors.at === loadedAt) return vectors.data;
|
|
1004
|
+
const data = new Float32Array(pages.length * m.dims);
|
|
1005
|
+
pages.forEach((p, i) => data.set(embed(m, [p.title, p.summary, ...p.tags ?? []].filter(Boolean).join(". ")), i * m.dims));
|
|
1006
|
+
vectors = { at: loadedAt, data };
|
|
1007
|
+
return data;
|
|
1008
|
+
}
|
|
1009
|
+
function fuse(lists, k = 60) {
|
|
1010
|
+
const score = /* @__PURE__ */ new Map();
|
|
1011
|
+
for (const list of lists) list.forEach((p, i) => score.set(p, (score.get(p) ?? 0) + 1 / (k + i + 1)));
|
|
1012
|
+
return [...score].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([p]) => p);
|
|
1013
|
+
}
|
|
1014
|
+
function keepOf(a) {
|
|
1015
|
+
const lc = (x) => x?.trim().toLowerCase() || void 0;
|
|
1016
|
+
const type = lc(a.type), tier = lc(a.tier), country = lc(a.country), app = lc(a.app), kind = lc(a.kind);
|
|
1017
|
+
const system = a.system ? systemOf(a.system) ?? lc(a.system) : void 0;
|
|
1018
|
+
const objectType = a.object_type ? typeOfWord(a.object_type.replace(/\s+/g, "")) ?? lc(a.object_type) : void 0;
|
|
1019
|
+
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);
|
|
1020
|
+
}
|
|
1021
|
+
async function toolSearch(a) {
|
|
1022
|
+
await loadIndex();
|
|
1023
|
+
const keep2 = keepOf(a);
|
|
1024
|
+
const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
|
|
1025
|
+
const q = parseQuery(a.query, { countries: countriesOf(index) });
|
|
1026
|
+
const hits = search(index, q, { filter: keep2 });
|
|
1027
|
+
const byId = new Map(hits.map((h) => [h.r.id, h.r]));
|
|
1028
|
+
let semantic = [], note = "";
|
|
1029
|
+
if (mode !== "keyword") {
|
|
1030
|
+
const m = await loadModel();
|
|
1031
|
+
if (m) {
|
|
1032
|
+
const v = embed(m, a.query), data = pageVectors(m);
|
|
1033
|
+
if (v.some((x) => x !== 0)) {
|
|
1034
|
+
const scored = [];
|
|
1035
|
+
index.records.forEach((r, i) => {
|
|
1036
|
+
if (r.kind === "page" && i < pages.length && keep2(r)) scored.push([dot(v, data, i * m.dims), i]);
|
|
1037
|
+
});
|
|
1038
|
+
semantic = scored.sort((x, y) => y[0] - x[0]).slice(0, POOL).map(([, i]) => {
|
|
1039
|
+
const r = index.records[i];
|
|
1040
|
+
byId.set(r.id, r);
|
|
1041
|
+
return r.id;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
} else if (mode === "semantic") note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
|
|
1045
|
+
}
|
|
1046
|
+
const keyword = hits.map((h) => h.r.id);
|
|
1047
|
+
let order, exactBand = 0;
|
|
1048
|
+
if (mode === "semantic") order = semantic.length ? semantic : keyword.slice(0, POOL);
|
|
1049
|
+
else if (mode === "keyword" || !semantic.length) order = keyword;
|
|
1050
|
+
else {
|
|
1051
|
+
const exact = hits.filter((h) => h.band === "exact").map((h) => h.r.id), pinned = new Set(exact);
|
|
1052
|
+
exactBand = exact.length;
|
|
1053
|
+
order = [...exact, ...fuse([hits.filter((h) => h.band !== "exact").slice(0, POOL).map((h) => h.r.id), semantic.filter((p) => !pinned.has(p))])];
|
|
1054
|
+
}
|
|
1055
|
+
if (mode !== "semantic" && mode !== "keyword" && !semantic.length) exactBand = hits.filter((h) => h.band === "exact").length;
|
|
1056
|
+
const inKeyword = new Set(keyword.slice(0, Math.max(POOL, limit))), inMeaning = new Set(semantic);
|
|
1057
|
+
const tag = (id) => inKeyword.has(id) && inMeaning.has(id) ? " [both]" : inMeaning.has(id) ? " [meaning]" : " [keyword]";
|
|
1058
|
+
const shown = order.slice(0, limit).map((id) => byId.get(id)).filter(Boolean);
|
|
1059
|
+
const hs = hints(index, q, hits).filter((h) => !hits.length || hits[0].s < 6 || q.question || !!q.ref || /: no page; /.test(h.text));
|
|
1060
|
+
const hintText = hs.length ? `
|
|
1061
|
+
${hs.map((h) => `Hint: ${h.text}${h.query ? ` (search "${h.query}")` : h.path ? ` (cat("${h.path}"))` : ""}`).join("\n")}` : "";
|
|
1062
|
+
if (!shown.length) return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.${note}${hintText}`;
|
|
1063
|
+
const how = semantic.length && keyword.length && mode === "hybrid" ? "keyword + meaning" : mode === "semantic" && semantic.length ? "meaning" : "keyword";
|
|
1064
|
+
return `${shown.length} results, ${how}${note}${how === "keyword + meaning" || mode === "hybrid" ? ` (exact band: ${exactBand})` : ""} (read one with cat(path)):
|
|
1065
|
+
${shown.map((r) => hitLine(r, tag(r.id))).join("\n")}${hintText}`;
|
|
1066
|
+
}
|
|
1067
|
+
async function toolLs(a) {
|
|
1068
|
+
await loadIndex();
|
|
1069
|
+
const prefix = (a.path ?? "").replace(/^\/+|\/+$/g, "");
|
|
1070
|
+
const under = pages.filter((p) => !prefix || p.path === prefix || p.path.startsWith(`${prefix}/`));
|
|
1071
|
+
if (!under.length) return `Nothing under "${prefix}". Sections: ${[...new Set(pages.map((p) => p.path.split("/")[0]))].sort().join(", ")}.`;
|
|
1072
|
+
const depth = prefix ? prefix.split("/").length : 0;
|
|
1073
|
+
const dirs = /* @__PURE__ */ new Map(), files = [];
|
|
1074
|
+
for (const p of under) {
|
|
1075
|
+
const parts2 = p.path.split("/");
|
|
1076
|
+
if (parts2.length === depth + 1) files.push(p);
|
|
1077
|
+
else if (parts2.length > depth + 1) dirs.set(parts2.slice(0, depth + 1).join("/"), (dirs.get(parts2.slice(0, depth + 1).join("/")) ?? 0) + 1);
|
|
1078
|
+
}
|
|
1079
|
+
const MAX = 200;
|
|
1080
|
+
const out = [...[...dirs].sort().map(([d, n]) => `${d}/ (${n} pages)`), ...files.slice(0, MAX).map((f) => `${f.path}: ${f.title}`)];
|
|
1081
|
+
return `${out.join("\n")}${files.length > MAX ? `
|
|
1082
|
+
... and ${files.length - MAX} more pages; use search() to narrow down` : ""}`;
|
|
1083
|
+
}
|
|
1084
|
+
async function toolCat(a) {
|
|
1085
|
+
return pageMarkdown(a.path.replace(/#.*$/, ""));
|
|
1086
|
+
}
|
|
1087
|
+
async function toolGetObject(a) {
|
|
1088
|
+
await loadIndex();
|
|
1089
|
+
const type = typeOfWord(a.type.toLowerCase().replace(/\s+/g, "")) ?? a.type.toLowerCase().replace(/\s+/g, "");
|
|
1090
|
+
let want = a.idOrName.trim().replace(/^"|"$/g, "");
|
|
1091
|
+
let country = a.country?.trim().toLowerCase() || null;
|
|
1092
|
+
const objs = index.records.filter((r) => r.kind === "page" && r.type === "object" && r.objectType === type);
|
|
1093
|
+
let id = null;
|
|
1094
|
+
const q = parseQuery(want, { countries: countriesOf(index) });
|
|
1095
|
+
if (q.ref && q.ref.type === type) {
|
|
1096
|
+
id = q.ref.id;
|
|
1097
|
+
country ??= q.ref.country;
|
|
1098
|
+
} else if (q.number !== null) id = q.number;
|
|
1099
|
+
else {
|
|
1100
|
+
const m = /^(\d+)-([a-z]{2})$/i.exec(want);
|
|
1101
|
+
if (m) {
|
|
1102
|
+
id = Number(m[1]);
|
|
1103
|
+
country ??= m[2].toLowerCase();
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
const cc = /^(.*?)\s*\(([A-Za-z]{2})\)$/.exec(want);
|
|
1107
|
+
if (id === null && cc) {
|
|
1108
|
+
want = cc[1];
|
|
1109
|
+
country ??= cc[2].toLowerCase();
|
|
1110
|
+
}
|
|
1111
|
+
const lw = want.toLowerCase();
|
|
1112
|
+
const tiers = id !== null ? [objs.filter((r) => r.objectId === id)] : [objs.filter((r) => r.name.toLowerCase() === lw), objs.filter((r) => (r.caption ?? "").toLowerCase() === lw)];
|
|
1113
|
+
for (const found of tiers) {
|
|
1114
|
+
if (!found.length) continue;
|
|
1115
|
+
const inCountry = country ? found.filter((r) => r.country === country) : [];
|
|
1116
|
+
const w1 = found.filter((r) => !r.country);
|
|
1117
|
+
const pick = country ? inCountry.length ? inCountry : w1 : w1.length ? w1 : found;
|
|
1118
|
+
if (pick.length === 1) {
|
|
1119
|
+
const twins = found.filter((r) => r !== pick[0] && r.country).map((r) => r.country.toUpperCase());
|
|
1120
|
+
const note = country && !inCountry.length ? `(${country.toUpperCase()} has no ${type} of its own here; the W1 ${type} applies.)
|
|
1121
|
+
|
|
1122
|
+
` : "";
|
|
1123
|
+
const also = !country && twins.length ? `
|
|
1124
|
+
|
|
1125
|
+
(Also in ${twins.join(", ")}: get_object("${type}", "${pick[0].objectId ?? pick[0].name}", country).)` : "";
|
|
1126
|
+
return `${note}${await pageMarkdown(pick[0].id)}${also}`;
|
|
1127
|
+
}
|
|
1128
|
+
if (pick.length > 1) return `${pick.length} ${type} objects match "${a.idOrName}"; read one with cat(path):
|
|
1129
|
+
${pick.map((r) => `- ${r.title} path=${r.id}`).join("\n")}`;
|
|
1130
|
+
}
|
|
1131
|
+
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.`;
|
|
1132
|
+
}
|
|
1133
|
+
async function toolDiffObject(a) {
|
|
1134
|
+
const key = `${a.type.toLowerCase()}/${a.id.toLowerCase()}`;
|
|
1135
|
+
let diff;
|
|
1136
|
+
try {
|
|
1137
|
+
diff = JSON.parse(await dataFile(`code/diffs/version/${a.from}__${a.to}.json`));
|
|
1138
|
+
} catch {
|
|
1139
|
+
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].`;
|
|
1140
|
+
}
|
|
1141
|
+
const o = diff.objects.find((x) => x.key === key);
|
|
1142
|
+
if (!o) return `${key} did not change between BC${a.from} and BC${a.to} (or is not a W1 object).`;
|
|
1143
|
+
return `${key} "${o.name}" BC${a.from} -> BC${a.to}: ${o.change}
|
|
1144
|
+
${JSON.stringify(o, null, 1)}`;
|
|
1145
|
+
}
|
|
1146
|
+
async function toolLocalization(a) {
|
|
1147
|
+
return pageMarkdown(`localizations/${a.country.toLowerCase()}`);
|
|
1148
|
+
}
|
|
1149
|
+
async function toolWhatsNew(a, today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
|
|
1150
|
+
await loadIndex();
|
|
1151
|
+
const since = a.since.slice(0, 10);
|
|
1152
|
+
const dated = pages.filter((p) => p.date && p.date >= since && (!a.type || p.type === a.type));
|
|
1153
|
+
const future = dated.filter((p) => p.date > today);
|
|
1154
|
+
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);
|
|
1155
|
+
const tail = !a.include_future && future.length ? `
|
|
1156
|
+
${future.length} roadmap ${future.length === 1 ? "feature" : "features"} scheduled after today: pass include_future: true` : "";
|
|
1157
|
+
if (!items.length) return `Nothing dated on or after ${since}${a.include_future ? "" : ` up to today (${today})`}.${tail}`;
|
|
1158
|
+
return `${items.length} items since ${since}, newest first:
|
|
1159
|
+
${items.map((p) => line(p)).join("\n")}${tail}`;
|
|
1160
|
+
}
|
|
1161
|
+
async function toolBlogFootprint(a) {
|
|
1162
|
+
await loadIndex();
|
|
1163
|
+
const items = pages.filter((p) => p.source === a.source).sort((x, y) => (y.date ?? "").localeCompare(x.date ?? ""));
|
|
1164
|
+
if (!items.length) return `No posts or videos from source "${a.source}" yet. Source ids are listed in ${REPO}/blob/main/sources.yaml.`;
|
|
1165
|
+
const systems = /* @__PURE__ */ new Map();
|
|
1166
|
+
for (const p of items) if (p.system) systems.set(p.system, (systems.get(p.system) ?? 0) + 1);
|
|
1167
|
+
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(", ")}.
|
|
1168
|
+
${items.slice(0, 20).map((p) => line(p)).join("\n")}`;
|
|
1169
|
+
}
|
|
1170
|
+
function toolFeedback(a) {
|
|
1171
|
+
const title = `Feedback on ${a.path}`;
|
|
1172
|
+
const body = `Page: ${url(a.path)}
|
|
1173
|
+
|
|
1174
|
+
${a.message}
|
|
1175
|
+
|
|
1176
|
+
(sent from the bc-observatory MCP server ${VERSION})`;
|
|
1177
|
+
return `Open this link to file the issue (no sign-in through the agent): ${REPO}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}&labels=feedback`;
|
|
1178
|
+
}
|
|
1179
|
+
function createServer() {
|
|
1180
|
+
const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
|
|
1181
|
+
instructions: "BC Observatory: an agent-first knowledge base of Microsoft Dynamics 365 Business Central (Learn hubs, AL objects from the code for BC28-30, localizations, roadmap features, videos, community posts, code changes: merged pull requests of Microsoft's Business Central repositories, BCApps joined to the AL objects they changed, under changes/, and AL Language extension releases (the marketplace changelog, under releases/)). Every page carries a trust tier (official = Microsoft, community = everyone else) and a review state: say which tier a claim comes from. Never invent AL object ids or version numbers: look them up with get_object. Start with search(), read pages with cat(path), browse with ls(path)."
|
|
1182
|
+
});
|
|
1183
|
+
server.registerTool(
|
|
1184
|
+
"search",
|
|
1185
|
+
{
|
|
1186
|
+
title: "Search the knowledge base",
|
|
1187
|
+
description: "Search every page (title, AL object name and caption, summary, tags) with the site's own scorer: per query word title or caption 3, tags 2, summary 1, through the exact word, a prefix, the plural, a synonym (client = customer, G/L = general ledger) or one typo; a name equal to the query +10; an object reference ('t36', 'cu 80', 'table 11300 BE') or a bare id ('36') goes straight to the object; base app before first-party app before country layer, then an object's importance (what references, calls and documents it); hubs by size and review state. Hybrid by default: the exact band (references and exact names) stays on top and the rest is fused with a meaning ranking, so a paraphrase still finds the page; each hit says [keyword], [meaning] or [both]. mode 'keyword' for the scorer alone, 'semantic' for meaning only. Fields, published events, global procedures and enum values (W1 and apps, the major the object pages are built from) are searchable too ('OnAfterPostSalesDoc', 'Posting Date', or a prefix: 'event: OnAfterPost', 'field: Posting Date', 'proc: CopyToTempLines'); a symbol hit names its object and its path ends in #event-<Name>, #field-<id>, #proc-<Name> or #value-<ordinal>. Filters: type (topic, app, feature, object, localization, video, post, change, source, digest, release), tier (official, community), system (finance, sales, ... or an alias such as 'g/l'), country ('BE'), app ('Subscription Billing'), object_type ('table'), kind (page, field, event, proc, value).",
|
|
1188
|
+
inputSchema: { query: z.string(), type: z.string().optional(), tier: z.string().optional(), system: z.string().optional(), country: z.string().optional(), app: z.string().optional(), object_type: z.string().optional(), kind: z.enum(["page", "field", "event", "proc", "value"]).optional(), limit: z.number().int().min(1).max(50).optional(), mode: z.enum(["hybrid", "keyword", "semantic"]).optional() }
|
|
1189
|
+
},
|
|
1190
|
+
async (a) => text(await toolSearch(a))
|
|
1191
|
+
);
|
|
1192
|
+
server.registerTool(
|
|
1193
|
+
"ls",
|
|
1194
|
+
{ 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() } },
|
|
1195
|
+
async (a) => text(await toolLs(a))
|
|
1196
|
+
);
|
|
1197
|
+
server.registerTool(
|
|
1198
|
+
"cat",
|
|
1199
|
+
{ title: "Read a page", description: "The full markdown of a page (frontmatter with evidence and links, then the body), by its path from search or ls.", inputSchema: { path: z.string() } },
|
|
1200
|
+
async (a) => text(await toolCat(a))
|
|
1201
|
+
);
|
|
1202
|
+
server.registerTool("get_object", {
|
|
1203
|
+
title: "Look up an AL object",
|
|
1204
|
+
description: "An AL object of W1, a Microsoft first-party app or a country layer by type and id, reference or name, e.g. ('table', '18'), ('table', 't36'), ('table', '36-be'), ('codeunit', 'Sales-Post'), ('page', 'Customers') by its caption, ('table', 'VAT VIES Correction') or with country 'BE': fields, keys, events, public procedures, obsolete state, versions, countries that replace it, Learn pages. A W1 object wins over its country twins unless a country is given; two candidates of the same standing are listed with their paths.",
|
|
1205
|
+
inputSchema: { type: z.string(), idOrName: z.string(), country: z.string().length(2).optional() }
|
|
1206
|
+
}, async (a) => text(await toolGetObject(a)));
|
|
1207
|
+
server.registerTool("diff_object", {
|
|
1208
|
+
title: "What changed in an object",
|
|
1209
|
+
description: "Member-level changes of a W1 object between two consecutive versions (fields, events, procedures, keys, properties, obsolete state).",
|
|
1210
|
+
inputSchema: { type: z.string(), id: z.string(), from: z.string(), to: z.string() }
|
|
1211
|
+
}, async (a) => text(await toolDiffObject(a)));
|
|
1212
|
+
server.registerTool(
|
|
1213
|
+
"localization",
|
|
1214
|
+
{ title: "A country localization", description: "What a country layer (BE, NL, DE, ...) adds to or changes in W1, with its Learn local functionality hub.", inputSchema: { country: z.string().length(2) } },
|
|
1215
|
+
async (a) => text(await toolLocalization(a))
|
|
1216
|
+
);
|
|
1217
|
+
server.registerTool("whats_new", {
|
|
1218
|
+
title: "What is new",
|
|
1219
|
+
description: "Videos, posts, roadmap features, AL extension releases (type 'release') and code changes (merged pull requests of BCApps, AL-Go and BCQuality, type 'change') dated on or after a date (YYYY-MM-DD) and up to today, newest first; a roadmap feature's date is its GA month, so features scheduled after today are counted at the end and listed with include_future: true.",
|
|
1220
|
+
inputSchema: { since: z.string(), type: z.string().optional(), limit: z.number().int().min(1).max(100).optional(), include_future: z.boolean().optional() }
|
|
1221
|
+
}, async (a) => text(await toolWhatsNew(a)));
|
|
1222
|
+
server.registerTool("blog_footprint", {
|
|
1223
|
+
title: "A source's footprint",
|
|
1224
|
+
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.",
|
|
1225
|
+
inputSchema: { source: z.string() }
|
|
1226
|
+
}, async (a) => text(await toolBlogFootprint(a)));
|
|
1227
|
+
server.registerTool("feedback", {
|
|
1228
|
+
title: "Report a problem with a page",
|
|
1229
|
+
description: "Returns a prefilled GitHub issue link for a page (wrong fact, missing evidence, broken link). Nothing is sent by the agent.",
|
|
1230
|
+
inputSchema: { path: z.string(), message: z.string() }
|
|
1231
|
+
}, async (a) => text(toolFeedback(a)));
|
|
1232
|
+
return server;
|
|
1233
|
+
}
|
|
1234
|
+
function isMain() {
|
|
1235
|
+
if (!process.argv[1]) return false;
|
|
1236
|
+
try {
|
|
1237
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
1238
|
+
} catch {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
289
1241
|
}
|
|
290
1242
|
if (isMain()) {
|
|
291
|
-
|
|
1243
|
+
await createServer().connect(new StdioServerTransport());
|
|
292
1244
|
}
|
|
1245
|
+
export {
|
|
1246
|
+
createServer,
|
|
1247
|
+
fuse,
|
|
1248
|
+
keepOf,
|
|
1249
|
+
toolBlogFootprint,
|
|
1250
|
+
toolCat,
|
|
1251
|
+
toolDiffObject,
|
|
1252
|
+
toolFeedback,
|
|
1253
|
+
toolGetObject,
|
|
1254
|
+
toolLocalization,
|
|
1255
|
+
toolLs,
|
|
1256
|
+
toolSearch,
|
|
1257
|
+
toolWhatsNew
|
|
1258
|
+
};
|