bc-observatory 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # bc-observatory (MCP server)
2
2
 
3
3
  [BC Observatory](https://waldo1001.github.io/waldo.BCObservatory/) for agents: an agent-first, cross-referenced knowledge base of
4
- Microsoft Dynamics 365 Business Central (Learn topic hubs, AL objects extracted from the code for BC28-30, country localizations,
4
+ Microsoft Dynamics 365 Business Central (Learn topic hubs, AL objects extracted from the code for BC28-30 with history back to BC23, country localizations,
5
5
  Microsoft 365 roadmap features, videos and community posts), every claim with a trust tier and evidence.
6
6
 
7
7
  ```bash
@@ -16,4 +16,11 @@ The server downloads the site's search index (cached by hash in `~/.cache/bc-obs
16
16
  markdown. `BC_OBSERVATORY_LOCAL=<checkout of waldo1001/waldo.BCObservatory>` reads everything from disk instead;
17
17
  `BC_OBSERVATORY_SITE` points at another deployment.
18
18
 
19
+ `search` is hybrid: keywords plus meaning, so "who is allowed to see what" finds the permission sets. Meaning comes from
20
+ static embeddings ([model2vec](https://github.com/MinishLab/model2vec) `minishlab/potion-base-8M`, MIT) computed locally in
21
+ plain JavaScript; the 30 MB model is downloaded once on the first search, pinned by revision and checked by SHA-256. Pass
22
+ `mode: "keyword"` for exact terms such as an object name. `BC_OBSERVATORY_EMBEDDINGS=0` turns it off, and
23
+ `BC_OBSERVATORY_MODEL_DIR=<dir with config.json, tokenizer.json, model.safetensors>` uses a model on disk (offline). Without a
24
+ model, search is keyword-only and says so.
25
+
19
26
  MIT. Content: see the repository's CONTENT-NOTICE.md (Microsoft content attributed; community content derived only).
package/dist/embed.js ADDED
@@ -0,0 +1,133 @@
1
+ export const MAX_TOKENS = 512;
2
+ /** tokenizer.json (WordPiece), model.safetensors (one F32 tensor) and config.json into a model. */
3
+ export function parseModel(tokenizerJson, safetensors, config = {}) {
4
+ const t = JSON.parse(tokenizerJson);
5
+ if (t.model.type !== "WordPiece")
6
+ throw new Error(`unsupported tokenizer model ${t.model.type}`);
7
+ const vocab = new Map(Object.entries(t.model.vocab));
8
+ const view = new DataView(safetensors.buffer, safetensors.byteOffset, safetensors.byteLength);
9
+ const headerLen = Number(view.getBigUint64(0, true));
10
+ const header = JSON.parse(new TextDecoder().decode(safetensors.subarray(8, 8 + headerLen)));
11
+ const name = Object.keys(header).find((k) => k !== "__metadata__");
12
+ if (!name)
13
+ throw new Error("safetensors file has no tensor");
14
+ const { dtype, shape, data_offsets: [start, end] } = header[name];
15
+ if (dtype !== "F32" || shape.length !== 2)
16
+ throw new Error(`unsupported tensor ${name}: ${dtype} ${shape.join("x")}`);
17
+ // copy into an aligned buffer: the data starts at 8 + headerLen, which need not be a multiple of 4
18
+ const bytes = safetensors.slice(8 + headerLen + start, 8 + headerLen + end);
19
+ const matrix = new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
20
+ if (matrix.length !== shape[0] * shape[1])
21
+ throw new Error("tensor size does not match its shape");
22
+ const unk = t.model.unk_token !== undefined ? (vocab.get(t.model.unk_token) ?? null) : null;
23
+ 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 ?? "##" };
24
+ }
25
+ const CJK = [[0x4e00, 0x9fff], [0x3400, 0x4dbf], [0x20000, 0x2a6df], [0x2a700, 0x2b73f], [0x2b740, 0x2b81f], [0x2b820, 0x2ceaf], [0xf900, 0xfaff], [0x2f800, 0x2fa1f]];
26
+ const isCjk = (cp) => CJK.some(([a, b]) => cp >= a && cp <= b);
27
+ const isPunct = (ch, cp) => (cp >= 33 && cp <= 47) || (cp >= 58 && cp <= 64) || (cp >= 91 && cp <= 96) || (cp >= 123 && cp <= 126) || /\p{P}/u.test(ch);
28
+ /** BertNormalizer: clean text, CJK ideographs spaced out, accents stripped, lowercased. */
29
+ export function normalize(text) {
30
+ let out = "";
31
+ for (const ch of text) {
32
+ const cp = ch.codePointAt(0);
33
+ if (cp === 0 || cp === 0xfffd)
34
+ continue;
35
+ if (ch === "\t" || ch === "\n" || ch === "\r" || /\s/u.test(ch)) {
36
+ out += " ";
37
+ continue;
38
+ }
39
+ if (/[\p{Cc}\p{Cf}]/u.test(ch))
40
+ continue;
41
+ out += isCjk(cp) ? ` ${ch} ` : ch;
42
+ }
43
+ return out.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
44
+ }
45
+ /** BertPreTokenizer: words split on whitespace, every punctuation character a word of its own. */
46
+ export function preTokenize(text) {
47
+ const words = [];
48
+ let cur = "";
49
+ for (const ch of text) {
50
+ if (/\s/u.test(ch)) {
51
+ if (cur)
52
+ words.push(cur);
53
+ cur = "";
54
+ continue;
55
+ }
56
+ if (isPunct(ch, ch.codePointAt(0))) {
57
+ if (cur)
58
+ words.push(cur);
59
+ words.push(ch);
60
+ cur = "";
61
+ continue;
62
+ }
63
+ cur += ch;
64
+ }
65
+ if (cur)
66
+ words.push(cur);
67
+ return words;
68
+ }
69
+ /** Token ids of a text: no special tokens, no [UNK], at most MAX_TOKENS. */
70
+ export function tokenize(m, text) {
71
+ const ids = [];
72
+ for (const word of preTokenize(normalize(text))) {
73
+ const chars = [...word];
74
+ if (chars.length > m.maxWordChars)
75
+ continue; // the whole word would be [UNK], which is dropped
76
+ const pieces = [];
77
+ let start = 0, bad = false;
78
+ while (start < chars.length) {
79
+ let end = chars.length, found;
80
+ while (start < end) {
81
+ const sub = (start > 0 ? m.prefix : "") + chars.slice(start, end).join("");
82
+ found = m.vocab.get(sub);
83
+ if (found !== undefined)
84
+ break;
85
+ end--;
86
+ }
87
+ if (found === undefined) {
88
+ bad = true;
89
+ break;
90
+ }
91
+ pieces.push(found);
92
+ start = end;
93
+ }
94
+ if (!bad)
95
+ for (const id of pieces)
96
+ if (id !== m.unk)
97
+ ids.push(id);
98
+ if (ids.length >= MAX_TOKENS)
99
+ return ids.slice(0, MAX_TOKENS);
100
+ }
101
+ return ids;
102
+ }
103
+ /** The text's vector: mean of its tokens' rows, L2-normalized (all zeros for a text with no known token). */
104
+ export function embed(m, text) {
105
+ const v = new Float32Array(m.dims);
106
+ const ids = tokenize(m, text);
107
+ if (!ids.length)
108
+ return v;
109
+ for (const id of ids) {
110
+ const off = id * m.dims;
111
+ for (let d = 0; d < m.dims; d++)
112
+ v[d] += m.matrix[off + d];
113
+ }
114
+ for (let d = 0; d < m.dims; d++)
115
+ v[d] /= ids.length;
116
+ if (m.normalize) {
117
+ let n = 0;
118
+ for (let d = 0; d < m.dims; d++)
119
+ n += v[d] * v[d];
120
+ n = Math.sqrt(n);
121
+ if (n > 0)
122
+ for (let d = 0; d < m.dims; d++)
123
+ v[d] /= n;
124
+ }
125
+ return v;
126
+ }
127
+ /** Dot product: the cosine similarity of two normalized vectors. */
128
+ export function dot(a, b, bOffset = 0) {
129
+ let s = 0;
130
+ for (let d = 0; d < a.length; d++)
131
+ s += a[d] * b[bOffset + d];
132
+ return s;
133
+ }
package/dist/server.js CHANGED
@@ -8,6 +8,11 @@
8
8
  * BC_OBSERVATORY_SITE to point at another deployment.
9
9
  *
10
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.
11
16
  */
12
17
  import { createHash } from "node:crypto";
13
18
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
@@ -18,7 +23,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
23
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
24
  import MiniSearch from "minisearch";
20
25
  import { z } from "zod";
21
- const VERSION = "0.1.1";
26
+ import { dot, embed, parseModel } from "./embed.js";
27
+ const VERSION = "0.2.0";
22
28
  const SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
23
29
  const LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
24
30
  const REPO = "https://github.com/waldo1001/waldo.BCObservatory";
@@ -85,15 +91,94 @@ async function loadIndex() {
85
91
  const url = (path) => `${SITE}${path}/`;
86
92
  const line = (r) => `- ${r.title} [${r.type}${r.tier ? `, ${r.tier}` : ""}${r.date ? `, ${r.date}` : ""}] path=${r.path}\n ${r.summary}`;
87
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
+ },
102
+ };
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;
109
+ 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")));
131
+ }
132
+ catch (e) {
133
+ modelError = String(e.message).slice(0, 160);
134
+ }
135
+ return model;
136
+ }
137
+ /** One vector per page (title, summary, tags), rebuilt when the index reloads: 22k pages take about 0.3 s. */
138
+ 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
+ }
88
153
  // ---------------------------------------------------------------------------------------------- tools
89
154
  export async function toolSearch(a) {
90
155
  await loadIndex();
91
- const hits = search.search(a.query, {
92
- filter: (r) => (!a.type || r.type === a.type) && (!a.tier || r.tier === a.tier) && (!a.system || r.system === a.system),
93
- }).slice(0, a.limit ?? 10);
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);
94
178
  if (!hits.length)
95
179
  return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.`;
96
- return `${hits.length} results (read one with cat(path)):\n${hits.map(line).join("\n")}`;
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")}`;
97
182
  }
98
183
  export async function toolLs(a) {
99
184
  await loadIndex();
@@ -135,7 +220,7 @@ export async function toolDiffObject(a) {
135
220
  diff = JSON.parse(await dataFile(`code/diffs/version/${a.from}__${a.to}.json`));
136
221
  }
137
222
  catch {
138
- return `No version diff ${a.from} -> ${a.to}. Diffs exist between consecutive snapshot versions (28 -> 29, 29 -> 30).`;
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].`;
139
224
  }
140
225
  const o = diff.objects.find((x) => x.key === key);
141
226
  if (!o)
@@ -174,8 +259,8 @@ export function createServer() {
174
259
  const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
175
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).",
176
261
  });
177
- server.registerTool("search", { title: "Search the knowledge base", description: "Full-text search over every page (title, summary, tags). Filter by type (topic, feature, object, localization, video, post), tier (official, community) or galaxy system (finance, sales, development, ...).",
178
- 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() } }, async (a) => text(await toolSearch(a)));
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)));
179
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)));
180
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)));
181
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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bc-observatory",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for BC Observatory: an agent-first, cross-referenced knowledge base of Microsoft Dynamics 365 Business Central (Learn, code, roadmap, videos, community blogs).",
5
5
  "type": "module",
6
6
  "bin": {