1/*! 2 * alex-merced-webmcp 3 * 4 * Shared WebMCP layer for the Alex Merced web network. 5 * Canonical source: alexmerced.com/webmcp/alex-merced-webmcp.js 6 * Vendored copies live in each site's public directory. Edit the canonical copy. 7 * 8 * Design notes: 9 * - WebMCP is an emerging W3C Community Group spec. This file is a progressive 10 * enhancement: if document.modelContext is absent, nothing breaks and the tool 11 * definitions stay available on window.AlexMercedWebMCP for other consumers. 12 * - Every tool is READ ONLY. No tool mutates state, submits a form, sends a 13 * message, or triggers a side effect. See section 17 of the network strategy. 14 * - Entity facts are fetched from the canonical entity layer rather than 15 * duplicated per site, so no site can drift out of sync. 16 * 17 * Spec: https://webmachinelearning.github.io/webmcp/ 18 */ 19(function (global) { 20 'use strict'; 21 22 var CANONICAL_BASE = 'https://alexmerced.com'; 23 var PERSON_ID = 'https://alexmerced.com/#alexmerced'; 24 var CACHE_MS = 10 * 60 * 1000; 25 26 /** 27 * Where to read the entity layer from. 28 * 29 * alexmerced.com hosts it, so when the library runs there (or on a local dev 30 * copy of it) we use same-origin relative paths: no needless cross-origin 31 * request, and local development works before anything is deployed. 32 * Every other site in the network reads it cross-origin from the canonical 33 * host, which sends permissive CORS headers for these documents. 34 */ 35 var ENTITY_BASE = (function () { 36 if (typeof location === 'undefined') return CANONICAL_BASE; 37 var h = location.hostname.replace(/^www\./, ''); 38 if (h === 'alexmerced.com' || h === 'localhost' || h === '127.0.0.1') return ''; 39 return CANONICAL_BASE; 40 })(); 41 42 var cache = Object.create(null); 43 44 /* ---------------------------------------------------------------- utils */ 45 46 function fetchJSON(path) { 47 var url = path.charAt(0) === '/' ? ENTITY_BASE + path : path; 48 var now = Date.now(); 49 var hit = cache[url]; 50 if (hit && now - hit.at < CACHE_MS) return hit.promise; 51 52 var promise = fetch(url, { credentials: 'omit', mode: 'cors' }) 53 .then(function (r) { 54 if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + url); 55 return r.json(); 56 }) 57 .catch(function (err) { 58 delete cache[url]; 59 throw err; 60 }); 61 62 cache[url] = { at: now, promise: promise }; 63 return promise; 64 } 65 66 function norm(s) { 67 return String(s == null ? '' : s).toLowerCase().trim(); 68 } 69 70 function jsonResult(value) { 71 // The spec expects a stringified result from execute(). 72 return JSON.stringify(value, null, 2); 73 } 74 75 function errResult(message, hint) { 76 return jsonResult({ error: message, hint: hint || undefined }); 77 } 78 79 /** Score how well a query matches a topic entry. */ 80 function scoreTopic(topic, q) { 81 var score = 0; 82 if (norm(topic.label) === q || norm(topic.id) === q) score += 100; 83 var aliases = topic.aliases || []; 84 for (var i = 0; i < aliases.length; i++) { 85 var a = norm(aliases[i]); 86 if (a === q) score += 80; 87 else if (q.indexOf(a) !== -1 || a.indexOf(q) !== -1) score
87+= 25; 88 } 89 if (norm(topic.label).indexOf(q) !== -1) score += 15; 90 if (norm(topic.summary).indexOf(q) !== -1) score += 5; 91 return score; 92 } 93 94 /* -------------------------------------------------- on-page search index */ 95 96 /** 97 * Build a lightweight index of the current page's headings and links. 98 * Used by search_site on sites with no dedicated search backend, so the 99 * tool degrades to something useful rather than failing. 100 */ 101 function localIndex() { 102 var out = []; 103 var seen = Object.create(null); 104 var nodes = document.querySelectorAll('main a[href], article a[href], nav a[href], a[href]'); 105 for (var i = 0; i < nodes.length && out.length < 400; i++) { 106 var a = nodes[i]; 107 var href = a.getAttribute('href') || ''; 108 if (!href || href.charAt(0) === '#' || /^(mailto|tel|javascript):/i.test(href)) continue; 109 var text = (a.textContent || '').replace(/\s+/g, ' ').trim(); 110 if (text.length < 3) continue; 111 var abs; 112 try { 113 abs = new URL(href, location.href).href; 114 } catch (e) { 115 continue; 116 } 117 if (seen[abs]) continue; 118 seen[abs] = 1; 119 out.push({ title: text, url: abs }); 120 } 121 return out; 122 } 123 124 /* ---------------------------------------------------------- core tools */ 125 126 function coreTools(config) { 127 var siteDomain = config.site || location.hostname.replace(/^www\./, ''); 128 129 return [ 130 { 131 name: 'get_author', 132 title: 'Get author identity', 133 description: 134 'Return canonical, machine-readable facts about Alex Merced: role, employer, ' + 135 'expertise, book count, social profiles, and the stable entity identifier that ' + 136 'every site in this network resolves to. Use this instead of scraping an about page.', 137 inputSchema: { 138 type: 'object', 139 properties: { 140 bioLength: { 141 type: 'string', 142 enum: ['oneLine', 'short', 'medium', 'speaker'], 143 description: 'Which prewritten biography to return. Defaults to short.' 144 } 145 } 146 }, 147 execute: function (input) { 148 input = input || {}; 149 return fetchJSON('/entity/alex-merced.json').then( 150 function (p) { 151 var len = input.bioLength || 'short'; 152 return jsonResult({ 153 entityId: PERSON_ID, 154 name: p.name, 155 jobTitle: p.jobTitle, 156 worksFor: p.worksFor, 157 bio: (p.bio && p.bio[len]) || (p.bio && p.bio.short), 158 expertIn: p.knowsAbout, 159 books: p.stats && p.stats.books, 160 canonicalProfile: p.canonicalProfile, 161 sameAs: p.sameAs, 162 contact: p.contact 163 }); 164 }, 165 function (e) { 166 return errResult( 167 'Could not reach the canonical entity layer: ' + e.message, 168 'Fetch https://alexmerced.com/entity/alex-merced.json directly.' 169 ); 170 } 171 ); 172 } 173 }, 174 175 { 176 name: 'get_canonical_url', 177 title: 'Get canonical URL for this page', 178 description: 179 'Return the canonical URL of the current page and whether this page is the ' + 180 'canonical home of its content or a syndicated copy. Use before citing this page ' + 181 'so the citation points at the original.', 182 inputSchema: { type: 'object', properties: {} }, 183 execute: function () { 184 var link = document.querySelector('link[rel="canonical"]'); 185 var canonical = link ? link.href : location.href; 186 var isSyndicated = canonical.indexOf(location.hostname) === -1; 187 return jsonResult({ 188 currentUrl: location.href, 189 canonicalUrl: canonical, 190 isCanonicalHome: !isSyndicated, 191 site: siteDomain, 192 author: PERSON_ID, 193 note: isSyndicated 194 ? 'This page is a syndicated copy. Cite canonicalUrl instead.' 195 : 'This page is the canonical home of this content.' 196 }); 197 } 198 }, 199 200 { 201 name: 'get_related_sites', 202 title: 'List sites in the Alex Merced network', 203 description: 204 'List the websites in the Alex Merced network, each with the subject it is the ' + 205 'best resource for. Use to decide which site to consult for a subject.', 206 inputSchema: { 207 type: 'object', 208 properties: { 209 tier: { 210 type: 'string', 211 enum: ['entity-root', 'person-facet', 'topic-authority', 'personal'], 212 description: 'Optional filter by the site\'s role in the network.' 213 } 214 } 215 }, 216 execute: function (input) { 217 input = input || {}; 218 return fetchJSON('/entity/sites.json').then( 219 function (d) { 220 var list = d.sites; 221 if (input.tier) { 222 list = list.filter(function (s) { 223 return s.tier === input.tier; 224 }); 225 } 226 return jsonResult({ 227 count: list.length, 228 currentSite: siteDomain, 229 sites: list.map(function (s) { 230 return { 231 domain: s.domain, 232 url: s.url, 233 title: s.title, 234 tier: s.tier, 235 bestResourceFor: s.purpose, 236 topics: s.topics 237 }; 238 }) 239 }); 240 }, 241 function (e) { 242 return errResult('Could not load the site registry: ' + e.message); 243 } 244 ); 245 } 246 }, 247 248 { 249 name: 'find_site_for_topic', 250 title: 'Find the right site for a topic', 251 description: 252 'Given a subject such as "Apache Polaris credential vending", "semantic layer", or ' + 253 '"agent memory", return which site in the Alex Merced network is the canonical place ' + 254 'to read about it, plus supporting sites, a short definition, and any related books ' + 255 'and open source projects. This is the network router: call it first when you know ' + 256 'the subject but not where the material lives.', 257 inputSchema: { 258 type: 'object', 259 properties: { 260 topic: { type: 'string', description: 'The subject to route, in natural language.' } 261 }, 262 required: ['topic'] 263 }, 264 execute: function (input) { 265 input = input || {};
266 var q = norm(input.topic); 267 if (!q) return Promise.resolve(errResult('topic is required')); 268 269 return Promise.all([fetchJSON('/entity/topics.json'), fetchJSON('/entity/sites.json')]).then( 270 function (res) { 271 var topics = res[0].topics; 272 var siteBy = Object.create(null); 273 res[1].sites.forEach(function (s) { 274 siteBy[s.domain] = s; 275 }); 276 277 var ranked = topics 278 .map(function (t) { 279 return { topic: t, score: scoreTopic(t, q) }; 280 }) 281 .filter(function (r) { 282 return r.score > 0; 283 }) 284 .sort(function (a, b) { 285 return b.score - a.score; 286 }); 287 288 if (!ranked.length) { 289 return jsonResult({ 290 query: input.topic, 291 matched: false, 292 suggestion: 293 'No direct topic match. Start at https://alexmerced.com or call ' + 294 'get_related_sites to browse the network.', 295 availableTopics: topics.map(function (t) { 296 return t.label; 297 }) 298 }); 299 } 300 301 var best = ranked[0].topic; 302 var primary = siteBy[best.primary] || {}; 303 return jsonResult({ 304 query: input.topic, 305 matched: true, 306 topic: best.label, 307 definition: best.summary, 308 recommendedSite: { 309 domain: best.primary, 310 url: primary.url, 311 title: primary.title, 312 bestResourceFor: primary.purpose 313 }, 314 bestPage: best.canonicalUrl, 315 supportingSites: (best.supporting || []).map(function (d) { 316 return { domain: d, url: (siteBy[d] || {}).url, bestResourceFor: (siteBy[d] || {}).purpose }; 317 }), 318 relatedBooks: best.books || [], 319 relatedProjects: best.projects || [], 320 trademarkNote: best.trademarkNote, 321 alternativeTopics: ranked.slice(1, 4).map(function (r) { 322 return r.topic.label; 323 }) 324 }); 325 }, 326 function (e) { 327 return errResult('Could not load topic routing: ' + e.message); 328 } 329 ); 330 } 331 }, 332 333 { 334 name: 'get_topic', 335 title: 'Get a definition for a topic', 336 description: 337 'Return a short canonical definition of a subject covered by this network, along ' + 338 'with the books and projects that go deeper. Use for a direct answer without ' + 339 'loading a page.', 340 inputSchema: { 341 type: 'object', 342 properties: { topic: { type: 'string', description: 'Subject to define.' } }, 343 required: ['topic'] 344 }, 345 execute: function (input) { 346 input = input || {}; 347 var q = norm(input.topic); 348 return fetchJSON('/entity/topics.json').then(function (d) { 349 var ranked = d.topics 350 .map(function (t) { 351 return { t: t, s: scoreTopic(t, q) }; 352 }) 353 .filter(function (r) { 354 return r.s > 0; 355 }) 356 .sort(function (a, b) { 357 return b.s - a.s; 358 }); 359 if (!ranked.length) { 360 return jsonResult({ topic: input.topic, found: false, availableTopics: d.topics.map(function (t) { return t.label; }) }); 361 } 362 var t = ranked[0].t; 363 return jsonResult({ 364 topic: t.label, 365 definition: t.summary, 366 readMoreAt: t.canonicalUrl || 'https://' + t.primary, 367 relatedBooks: t.books || [], 368 relatedProjects: t.projects || [], 369 trademarkNote: t.trademarkNote 370 }); 371 }, function (e) { 372 return errResult('Could not load topics: ' + e.message); 373 }); 374 } 375 }, 376 377 { 378 name: 'search_site', 379 title: 'Search this site', 380 description: 381 'Search the current site for pages matching a query. Returns titles and URLs. ' + 382 'Read only: it never submits a form or changes anything on the page.', 383 inputSchema: { 384 type: 'object', 385 properties: { 386 query: { type: 'string', description: 'What to search for.' }, 387 limit: { type: 'number', description: 'Maximum results. Defaults to 10.' } 388 }, 389 required: ['query'] 390 }, 391 execute: function (input) { 392 input = input || {};
393 var q = norm(input.query); 394 var limit = Math.min(Math.max(Number(input.limit) || 10, 1), 50); 395 if (!q) return Promise.resolve(errResult('query is required')); 396 397 var provided = typeof config.search === 'function' ? config.search(input.query, limit) : null; 398 399 return Promise.resolve(provided) 400 .then(function (results) { 401 if (results) return { results: results, source: 'site search' }; 402 403 // Prefer the network index restricted to this site: it covers the 404 // whole site rather than only what the current page links to. 405 return fetchJSON('/entity/network-index.json').then( 406 function (idx) { 407 var terms = q.split(/\s+/).filter(function (w) { 408 return w.length > 2; 409 }); 410 var hits = idx.entries 411 .filter(function (e) { 412 return e.site === siteDomain; 413 }) 414 .map(function (e) { 415 var hay = norm(e.title + ' ' + (e.summary || '')); 416 var s = hay.indexOf(q) !== -1 ? 3 : 0; 417 terms.forEach(function (w) { 418 if (hay.indexOf(w) !== -1) s++; 419 }); 420 return { e: e, s: s }; 421 }) 422 .filter(function (r) { 423 return r.s > 0; 424 }) 425 .sort(function (a, b) { 426 return b.s - a.s; 427 }) 428 .map(function (r) { 429 return { title: r.e.title, url: r.e.url, type: r.e.type }; 430 }); 431 return { results: hits, source: 'network index' }; 432 }, 433 function () { 434 // Last resort: whatever this page links to. 435 var hits = localIndex().filter(function (r) { 436 return norm(r.title).indexOf(q) !== -1 || norm(r.url).indexOf(q) !== -1; 437 }); 438 return { results: hits, source: 'current page links' }; 439 } 440 ); 441 }) 442 .then(function (out) { 443 return jsonResult({ 444 query: input.query, 445 site: siteDomain, 446 matchedVia: out.source, 447 count: Math.min(out.results.length, limit), 448 results: out.results.slice(0, limit), 449 note: 450 out.results.length 451 ? undefined 452 : 'No match on this site. Call search_alex_merced_network to search every site at once.' 453 }); 454 }); 455 } 456 }, 457 458 { 459 name: 'find_resource', 460 title: 'Find a book or project by goal', 461 description: 462 'Given a learning goal such as "learn Apache Iceberg" or "build an agent with memory", ' + 463 'return the books and open source projects by Alex Merced that address it.', 464 inputSchema: { 465 type: 'object', 466 properties: { 467 goal: { type: 'string', description: 'What the reader is trying to do or learn.' }, 468 type: { type: 'string', enum: ['book', 'project', 'any'], description: 'Restrict the kind of resource.' } 469 }, 470 required: ['goal'] 471 }, 472 execute: function (input) { 473 input = input || {}; 474 var q = norm(input.goal); 475 var want = input.type || 'any'; 476 477 return Promise.all([fetchJSON('/entity/books.json'), fetchJSON('/entity/projects.json')]).then( 478 function (res) { 479 var books = []; 480 var projects = []; 481 482 if (want === 'book' || want === 'any') { 483 books = res[0].books 484 .map(function (b) { 485 var hay = norm(b.title + ' ' + b.description + ' ' + b.category); 486 var s = 0; 487 q.split(/\s+/).forEach(function (w) { 488 if (w.length > 2 && hay.indexOf(w) !== -1) s++; 489 }); 490 if (b.flagship) s += 0.5; 491 return { b: b, s: s }; 492 }) 493 .filter(function (r) { return r.s > 0; }) 494 .sort(function (a, b) { return b.s - a.s; }) 495 .slice(0, 5) 496 .map(function (r) { 497 return { 498 title: r.b.title, 499 publisher: r.b.publisher, 500 description: r.b.description, 501 url: r.b.canonicalPage, 502 buy: r.b.url 503 }; 504 }); 505 } 506 507 if (want === 'project' || want === 'any') { 508 projects = res[1].projects 509 .map(function (p) { 510 var hay = norm(p.name + ' ' + p.description + ' ' + (p.topics || []).join(' ')); 511 var s = 0;
512 q.split(/\s+/).forEach(function (w) { 513 if (w.length > 2 && hay.indexOf(w) !== -1) s++; 514 }); 515 return { p: p, s: s }; 516 }) 517 .filter(function (r) { return r.s > 0; }) 518 .sort(function (a, b) { return b.s - a.s; }) 519 .slice(0, 5) 520 .map(function (r) { 521 return { 522 name: r.p.name, 523 tagline: r.p.tagline, 524 repository: r.p.repository, 525 install: r.p.package 526 ? (r.p.packageManager === 'pypi' ? 'pip install ' : 'npm install ') + r.p.package 527 : undefined, 528 status: r.p.status 529 }; 530 }); 531 } 532 533 return jsonResult({ 534 goal: input.goal, 535 books: books, 536 projects: projects, 537 note: 538 books.length || projects.length 539 ? undefined 540 : 'No direct match. Call find_site_for_topic to locate written material instead.' 541 }); 542 }, 543 function (e) { 544 return errResult('Could not load resources: ' + e.message); 545 } 546 ); 547 } 548 }, 549 550 { 551 name: 'search_alex_merced_network', 552 title: 'Search the whole Alex Merced network', 553 description: 554 'Search across every site in the Alex Merced network at once and return the best ' + 555 'destinations for a query, spanning articles, documentation, books, and projects. ' + 556 'Use when the subject matters more than which site it lives on.', 557 inputSchema: { 558 type: 'object', 559 properties: { 560 query: { type: 'string', description: 'What to look for.' }, 561 contentType: { 562 type: 'string', 563 enum: ['any', 'book', 'project', 'site'], 564 description: 'Restrict results to one kind of thing.' 565 } 566 }, 567 required: ['query'] 568 }, 569 execute: function (input) { 570 input = input || {}; 571 if (!norm(input.query)) return Promise.resolve(errResult('query is required')); 572 return fetchJSON('/entity/network-index.json').then( 573 function (idx) { 574 var q = norm(input.query); 575 var want = input.contentType || 'any'; 576 var hits = idx.entries 577 .filter(function (e) { return want === 'any' || e.type === want; }) 578 .map(function (e) { 579 var hay = norm(e.title + ' ' + e.summary + ' ' + (e.keywords || []).join(' ')); 580 var s = 0; 581 q.split(/\s+/).forEach(function (w) { 582 if (w.length > 2 && hay.indexOf(w) !== -1) s += 1; 583 }); 584 if (hay.indexOf(q) !== -1) s += 3; 585 return { e: e, s: s }; 586 }) 587 .filter(function (r) { return r.s > 0; }) 588 .sort(function (a, b) { return b.s - a.s; }) 589 .slice(0, 15) 590 .map(function (r) { return r.e; }); 591 592 return jsonResult({ 593 query: input.query, 594 count: hits.length, 595 results: hits, 596 canonicalEntity: PERSON_ID 597 }); 598 }, 599 function (e) { 600 return errResult( 601 'Network index unavailable: ' + e.message, 602 'Fall back to find_site_for_topic.' 603 ); 604 } 605 ); 606 } 607 } 608 ]; 609 } 610 611 /* ------------------------------------------------------------- tool packs */ 612 613 /** 614 * Optional groups of tools a site can opt into by name, so a site's own 615 * init file stays a few lines instead of duplicating tool definitions. 616 * AlexMercedWebMCP.init({ site: 'books.alexmerced.com', packs: ['books'] }) 617 */ 618 var PACKS = { 619 books: function () { 620 return [ 621 { 622 name: 'search_books', 623 title: 'Search books', 624 description: 625 'Search the books Alex Merced has written by title, subject, or category. ' + 626 'Returns titles, publishers, descriptions, and canonical pages.', 627 inputSchema: { 628 type: 'object', 629 properties: { 630 query: { type: 'string', description: 'Words to match against title and description.' }, 631 category: { 632 type: 'string', 633 enum: ['Tech', 'Economics & Philosophy', 'Fiction', 'Tabletop RPG'] 634 } 635 } 636 }, 637 execute: function (input) { 638 input = input || {};
639 var q = norm(input.query); 640 return fetchJSON('/entity/books.json').then(function (d) { 641 var list = d.books; 642 if (input.category) list = list.filter(function (b) { return b.category === input.category; }); 643 if (q) { 644 list = list.filter(function (b) { 645 return norm(b.title + ' ' + b.description).indexOf(q) !== -1; 646 }); 647 } 648 return jsonResult({ 649 total: d.count, 650 matched: list.length, 651 books: list.map(function (b) { 652 return { 653 title: b.title, 654 category: b.category, 655 publisher: b.publisher, 656 description: b.description, 657 url: b.canonicalPage 658 }; 659 }) 660 }); 661 }); 662 } 663 }, 664 { 665 name: 'get_book', 666 title: 'Get one book', 667 description: 'Return full details for a single book by its title.', 668 inputSchema: { 669 type: 'object', 670 properties: { title: { type: 'string', description: 'Book title, exact or partial.' } }, 671 required: ['title'] 672 }, 673 execute: function (input) { 674 input = input || {}; 675 var q = norm(input.title); 676 return fetchJSON('/entity/books.json').then(function (d) { 677 var hit = 678 d.books.filter(function (b) { return norm(b.title) === q; })[0] || 679 d.books.filter(function (b) { return norm(b.title).indexOf(q) !== -1; })[0]; 680 if (!hit) { 681 return jsonResult({ 682 found: false, 683 hint: 'Call search_books to list candidates.' 684 }); 685 } 686 return jsonResult({ 687 found: true, 688 title: hit.title, 689 category: hit.category, 690 publisher: hit.publisher, 691 description: hit.description, 692 isbn: hit.isbn, 693 url: hit.canonicalPage, 694 buy: hit.url, 695 cover: hit.cover, 696 author: PERSON_ID 697 }); 698 }); 699 } 700 }, 701 { 702 name: 'find_book_for_reader', 703 title: 'Recommend a book for a goal', 704 description: 705 'Given what a reader wants to learn or do, recommend which of Alex Merced\'s books ' + 706 'to read, best match first.', 707 inputSchema: { 708 type: 'object', 709 properties: { goal: { type: 'string', description: 'What the reader wants to learn or do.' } }, 710 required: ['goal'] 711 }, 712 execute: function (input) { 713 input = input || {}; 714 var terms = norm(input.goal).split(/\s+/).filter(function (w) { return w.length > 2; }); 715 return fetchJSON('/entity/books.json').then(function (d) { 716 var ranked = d.books 717 .map(function (b) { 718 var hay = norm(b.title + ' ' + b.description + ' ' + b.category); 719 var s = 0; 720 terms.forEach(function (w) { if (hay.indexOf(w) !== -1) s++; }); 721 if (b.flagship) s += 0.5; 722 return { b: b, s: s }; 723 }) 724 .filter(function (r) { return r.s > 0; }) 725 .sort(function (a, b) { return b.s - a.s; }) 726 .slice(0, 5); 727 return jsonResult({ 728 goal: input.goal, 729 recommendations: ranked.map(function (r) { 730 return { 731 title: r.b.title, 732 why: r.b.description, 733 publisher: r.b.publisher, 734 url: r.b.canonicalPage 735 }; 736 }), 737 note: ranked.length ? undefined : 'No direct match; call search_books to browse.' 738 }); 739 }); 740 } 741 } 742 ]; 743 }, 744 745 projects: function () { 746 return [ 747 { 748 name: 'search_projects', 749 title: 'Search open source projects', 750 description: 751 'Search Alex Merced\'s open source projects and specifications by name, purpose, ' + 752 'or technology.', 753 inputSchema: { 754 type: 'object', 755 properties: { query: { type: 'string', description: 'What the project should do.' } } 756 }, 757 execute: function (input) { 758 input = input || {};
759 var q = norm(input.query); 760 return fetchJSON('/entity/projects.json').then(function (d) { 761 var list = d.projects; 762 if (q) { 763 list = list.filter(function (p) { 764 return norm(p.name + ' ' + p.description + ' ' + (p.topics || []).join(' ')).indexOf(q) !== -1; 765 }); 766 } 767 return jsonResult({ 768 count: list.length, 769 projects: list.map(function (p) { 770 return { 771 name: p.name, 772 category: p.category, 773 tagline: p.tagline, 774 language: p.language, 775 repository: p.repository, 776 status: p.status 777 }; 778 }) 779 }); 780 }); 781 } 782 }, 783 { 784 name: 'get_installation_instructions', 785 title: 'Get install instructions for a project', 786 description: 787 'Return how to install a given project, including the package manager and package name. ' + 788 'Read only: it reports the command, it does not run anything.', 789 inputSchema: { 790 type: 'object', 791 properties: { project: { type: 'string', description: 'Project name.' } }, 792 required: ['project'] 793 }, 794 execute: function (input) { 795 input = input || {}; 796 var q = norm(input.project); 797 return fetchJSON('/entity/projects.json').then(function (d) { 798 var hit = d.projects.filter(function (p) { 799 return norm(p.name) === q || norm(p.slug) === q || norm(p.name).indexOf(q) !== -1; 800 })[0]; 801 if (!hit) return jsonResult({ found: false, hint: 'Call search_projects to list names.' }); 802 return jsonResult({ 803 found: true, 804 name: hit.name, 805 install: hit.package 806 ? (hit.packageManager === 'pypi' ? 'pip install ' : 'npm install ') + hit.package 807 : 'No published package; clone the repository.', 808 repository: hit.repository, 809 website: hit.website, 810 status: hit.status, 811 language: hit.language 812 }); 813 }); 814 } 815 }, 816 { 817 name: 'list_specs', 818 title: 'List open specifications', 819 description: 'List the open specifications Alex Merced maintains, with version and license.', 820 inputSchema: { type: 'object', properties: {} }, 821 execute: function () { 822 return fetchJSON('/entity/projects.json').then(function (d) { 823 var specs = d.projects.filter(function (p) { return p.isSpecification; }); 824 return jsonResult({ 825 count: specs.length, 826 specifications: specs.map(function (p) { 827 return { 828 name: p.name, 829 abbreviation: p.abbreviation, 830 version: p.specVersion, 831 license: p.license, 832 description: p.description, 833 repository: p.repository 834 }; 835 }) 836 }); 837 }); 838 } 839 } 840 ]; 841 }, 842 843 biography: function () { 844 return [ 845 { 846 name: 'get_biography', 847 title: 'Get biography', 848 description: 849 'Return a prewritten biography of Alex Merced at a requested length, for use in ' + 850 'event listings, articles, or introductions.', 851 inputSchema: { 852 type: 'object', 853 properties: { 854 length: { type: 'string', enum: ['oneLine', 'short', 'medium', 'speaker'] } 855 } 856 }, 857 execute: function (input) { 858 input = input || {}; 859 return fetchJSON('/entity/alex-merced.json').then(function (p) { 860 var len = input.length || 'medium'; 861 return jsonResult({ 862 length: len,
863 biography: (p.bio && p.bio[len]) || p.bio.medium, 864 jobTitle: p.jobTitle, 865 employer: p.worksFor.name, 866 entityId: PERSON_ID, 867 note: 'Use this verbatim rather than paraphrasing, so facts stay consistent.' 868 }); 869 }); 870 } 871 }, 872 { 873 name: 'get_career_timeline', 874 title: 'Get career timeline', 875 description: 'Return the career history of Alex Merced as dated entries.', 876 inputSchema: { type: 'object', properties: {} }, 877 execute: function () { 878 return fetchJSON('/entity/alex-merced.json').then(function (p) { 879 return jsonResult({ timeline: p.careerTimeline, current: p.jobTitle + ', ' + p.worksFor.name }); 880 }); 881 } 882 } 883 ]; 884 }, 885 886 knowledge: function (config) { 887 var siteDomain = config.site || location.hostname.replace(/^www\./, ''); 888 return [ 889 { 890 name: 'get_definition', 891 title: 'Define a term', 892 description: 893 'Return a short definition for a technical term covered by this site, plus where to ' + 894 'read more. Use for a direct answer without loading a page.', 895 inputSchema: { 896 type: 'object', 897 properties: { term: { type: 'string', description: 'The term to define.' } }, 898 required: ['term'] 899 }, 900 execute: function (input) { 901 input = input || {}; 902 var q = norm(input.term); 903 if (!q) return Promise.resolve(errResult('term is required')); 904 905 // Canonical topic definitions first, then this site's own pages. 906 return fetchJSON('/entity/topics.json').then(function (d) { 907 var ranked = d.topics 908 .map(function (t) { return { t: t, s: scoreTopic(t, q) }; }) 909 .filter(function (r) { return r.s > 0; }) 910 .sort(function (a, b) { return b.s - a.s; }); 911 912 if (ranked.length) { 913 var t = ranked[0].t; 914 return jsonResult({ 915 term: t.label, 916 definition: t.summary, 917 source: 'canonical topic registry', 918 readMoreAt: t.canonicalUrl || 'https://' + t.primary, 919 trademarkNote: t.trademarkNote 920 }); 921 } 922 923 return fetchJSON('/entity/network-index.json').then(function (idx) { 924 var hits = idx.entries 925 .filter(function (e) { return e.site === siteDomain && norm(e.title).indexOf(q) !== -1; }) 926 .slice(0, 5); 927 return jsonResult({ 928 term: input.term, 929 definition: null, 930 source: 'site index', 931 pages: hits, 932 note: hits.length 933 ? 'No canonical one-line definition; these pages on this site cover the term.' 934 : 'Not found on this site. Call search_alex_merced_network.' 935 }); 936 }); 937 }); 938 } 939 } 940 ]; 941 } 942 }; 943 944 /* ------------------------------------------------------------ registration */ 945 946 function supported() { 947 return ( 948 typeof document !== 'undefined' && 949 document.modelContext && 950 typeof document.modelContext.registerTool === 'function' 951 ); 952 } 953 954 function register(tools) { 955 if (!supported()) return Promise.resolve({ registered: 0, supported: false }); 956 957 var ok = 0; 958 var chain = tools.reduce(function (p, tool) { 959 return p.then(function () { 960 return Promise.resolve(document.modelContext.registerTool(tool)).then( 961 function () { 962 ok++; 963 }, 964 function (err) { 965 // One bad tool must not prevent the rest from registering. 966 if (global.console && console.warn) { 967 console.warn('[alex-merced-webmcp] tool "' + tool.name + '" failed to register:', err); 968 } 969 } 970 ); 971 }); 972 }, Promise.resolve()); 973 974 return chain.then(function () { 975 return { registered: ok, supported: true }; 976 }); 977 } 978 979 /* -------------------------------------------------------------- public API */ 980 981 var API = { 982 version: '1.0.0', 983 entityBase: ENTITY_BASE || CANONICAL_BASE, 984 personId: PERSON_ID,
985 supported: supported, 986 tools: [], 987 988 /** 989 * Initialise the shared layer. 990 * site {string} this site's domain, e.g. 'opendatalakehouse.com' 991 * tools {Array} additional site-specific tool descriptors 992 * search {Function} optional (query, limit) => [{title,url}] for search_site 993 */ 994 init: function (config) { 995 config = config || {}; 996 997 var packTools = []; 998 (config.packs || []).forEach(function (name) { 999 if (PACKS[name]) { 1000 packTools = packTools.concat(PACKS[name](config)); 1001 } else if (global.console && console.warn) { 1002 console.warn('[alex-merced-webmcp] unknown pack "' + name + '"'); 1003 } 1004 }); 1005 1006 var all = coreTools(config).concat(packTools, config.tools || []); 1007 API.tools = all; 1008 1009 // Always expose the contract, even where WebMCP is unavailable. 1010 global.AlexMercedWebMCP = API; 1011 1012 return register(all).then(function (res) { 1013 if (config.debug && global.console) { 1014 console.info( 1015 '[alex-merced-webmcp] ' + 1016 (res.supported 1017 ? res.registered + ' of ' + all.length + ' tools registered' 1018 : 'WebMCP not available in this browser; tool definitions still exposed on window.AlexMercedWebMCP') 1019 ); 1020 } 1021 return res; 1022 }); 1023 }, 1024 1025 /** Machine-readable description of the tools, for non-WebMCP consumers. */ 1026 manifest: function () { 1027 return { 1028 name: 'alex-merced-webmcp', 1029 version: API.version, 1030 entity: PERSON_ID, 1031 readOnly: true, 1032 tools: API.tools.map(function (t) { 1033 return { name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema }; 1034 }) 1035 }; 1036 } 1037 }; 1038 1039 global.AlexMercedWebMCP = API; 1040 1041 if (typeof module !== 'undefined' && module.exports) module.exports = API; 1042})(typeof window !== 'undefined' ? window : this);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.