PageSourceSearch

https://whoisalexmerced.com/webmcp/alex-merced-webmcp.js

js whoisalexmerced.com collected 2026-10-03 23:52:42 UTC 39,352 bytes, 1,042 lines download raw bytes

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.