@stackline/tool-router 1.0.0 → 1.0.1
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/CHANGELOG.md +21 -0
- package/README.md +8 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/index.min.js +1 -1
- package/docs/ARCHITECTURE.md +86 -0
- package/docs/BENCHMARKS.md +24 -0
- package/docs/COMPATIBILITY.md +47 -0
- package/docs/INTEGRATIONS.md +40 -0
- package/docs/MARKET_RESEARCH.md +93 -0
- package/docs/RELEASING.md +36 -0
- package/examples/anthropic.mjs +32 -0
- package/examples/dynamic-catalog.mjs +27 -0
- package/examples/gemini.mjs +22 -0
- package/examples/large-catalog.mjs +25 -0
- package/examples/mcp.mjs +23 -0
- package/examples/openai-chat.mjs +25 -0
- package/examples/openai-responses.mjs +45 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
All notable changes to this project are documented in this file. The format is
|
|
4
4
|
based on Keep a Changelog and the project follows Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [1.0.1] - 2026-08-21
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Executable examples for OpenAI Responses, OpenAI Chat, Anthropic, Gemini,
|
|
11
|
+
MCP, dynamic catalogs, and a 1,000-tool catalog.
|
|
12
|
+
- Provider integration guide and transparent benchmark methodology.
|
|
13
|
+
- Stackline package catalog links and first-party documentation analytics that
|
|
14
|
+
never record tool definitions or user queries.
|
|
15
|
+
- Trusted-publishing workflow for provenance-enabled future releases.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Package tarballs now include the public guides and provider examples.
|
|
20
|
+
- Documentation version checks now follow package metadata.
|
|
21
|
+
|
|
22
|
+
No runtime API or declaration behavior changed in this release.
|
|
23
|
+
|
|
6
24
|
## [1.0.0] - 2026-08-20
|
|
7
25
|
|
|
8
26
|
### Added
|
|
@@ -22,3 +40,6 @@ based on Keep a Changelog and the project follows Semantic Versioning.
|
|
|
22
40
|
cyclic schemas, oversized input, and malformed long queries.
|
|
23
41
|
- Reproducible benchmark corpus, public documentation, CI, CodeQL, package
|
|
24
42
|
smoke tests, and release artifact verification.
|
|
43
|
+
|
|
44
|
+
[1.0.1]: https://github.com/alexandroit/stackline-tool-router/compare/v1.0.0...v1.0.1
|
|
45
|
+
[1.0.0]: https://github.com/alexandroit/stackline-tool-router/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -326,11 +326,19 @@ Detailed runtime and declaration guarantees are in
|
|
|
326
326
|
## Documentation
|
|
327
327
|
|
|
328
328
|
- [Live routing workbench](https://alexandro.net/docs/vanilla/tool-router/)
|
|
329
|
+
- [OpenAI, Anthropic, Gemini, and MCP integrations](docs/INTEGRATIONS.md)
|
|
330
|
+
- [Evaluation and benchmark methodology](docs/BENCHMARKS.md)
|
|
331
|
+
- [Executable provider examples](examples)
|
|
329
332
|
- [Architecture](docs/ARCHITECTURE.md)
|
|
330
333
|
- [Market research](docs/MARKET_RESEARCH.md)
|
|
334
|
+
- [Stackline open-source catalog](https://alexandro.net/docs/open-source/)
|
|
331
335
|
- [Changelog](CHANGELOG.md)
|
|
332
336
|
- [Contributing](CONTRIBUTING.md)
|
|
333
337
|
|
|
338
|
+
Examples are included in the npm tarball and build provider request objects
|
|
339
|
+
without credentials or network calls. This makes format compatibility
|
|
340
|
+
executable before an application connects its own SDK.
|
|
341
|
+
|
|
334
342
|
## License
|
|
335
343
|
|
|
336
344
|
[MIT](LICENSE)
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/index.min.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
/*! @stackline/tool-router v1.0.
|
|
1
|
+
/*! @stackline/tool-router v1.0.1 | MIT */
|
|
2
2
|
var StacklineToolRouter=(()=>{var X=Object.defineProperty;var Oe=Object.getOwnPropertyDescriptor;var ye=Object.getOwnPropertyNames;var xe=Object.prototype.hasOwnProperty;var be=(t,e)=>{for(var n in e)X(t,n,{get:e[n],enumerable:!0})},Re=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of ye(e))!xe.call(t,i)&&i!==n&&X(t,i,{get:()=>e[i],enumerable:!(o=Oe(e,i))||o.enumerable});return t};var Ee=t=>Re(X({},"__esModule",{value:!0}),t);var Qe={};be(Qe,{TOOL_FORMATS:()=>G,ToolRouter:()=>$,ToolRouterError:()=>F,createToolRouter:()=>H,createToolSearch:()=>se,default:()=>Ge,defineTool:()=>ie,detectToolFormat:()=>Q,estimateToolTokens:()=>oe,normalizeIdentifier:()=>S,normalizeText:()=>q,normalizeTool:()=>j,normalizeTools:()=>v,routeTools:()=>ne,tokenize:()=>k});var F=class extends Error{constructor(e,n,o){super(n),this.name="ToolRouterError",this.code=e,o!==void 0&&(this.details=o)}};function f(t,e,n){throw new F(t,e,n)}var Le=new Set(["__proto__","prototype","constructor"]);function Se(t){return typeof t=="string"&&Le.has(t)}function y(t){return t!==null&&typeof t=="object"}function d(t,e){if(!y(t)&&typeof t!="function")return;let n=Object.getOwnPropertyDescriptor(t,e);return n&&Object.prototype.hasOwnProperty.call(n,"value")?n.value:void 0}function P(t){if(!y(t))return[];let e=Object.getOwnPropertyDescriptors(t),n=[];for(let o of Object.keys(e)){let i=e[o];!i.enumerable||!Object.prototype.hasOwnProperty.call(i,"value")||Se(o)||n.push([o,i.value])}return n}function N(t,e=""){return typeof t=="string"?t:e}function w(t,e=256){if(!Array.isArray(t))return[];let n=[];for(let o=0;o<t.length&&n.length<e;o++){let i=d(t,String(o));typeof i=="string"&&i.length>0&&n.push(i)}return n}function b(t,e,n,o){return t===void 0?e:((!Number.isInteger(t)||t<1||t>n)&&f("ERR_TOOL_ROUTER_OPTION",`${o} must be an integer between 1 and ${n}`),t)}function I(t,e,n,o,i){return t===void 0?e:((typeof t!="number"||!Number.isFinite(t)||t<n||t>o)&&f("ERR_TOOL_ROUTER_OPTION",`${i} must be a finite number between ${n} and ${o}`),t)}var G=Object.freeze(["canonical","mcp","openai-chat","openai-responses","anthropic","gemini"]);function Ae(t){if(!(t===void 0||t==="auto"))return G.includes(t)||f("ERR_TOOL_FORMAT",`Unsupported tool format: ${String(t)}`),t}function Q(t){y(t)||f("ERR_TOOL_DEFINITION","Tool definition must be an object");let e=d(t,"type"),n=d(t,"function");if(e==="function"&&y(n))return"openai-chat";if(e==="function"&&typeof d(t,"name")=="string")return"openai-responses";if(d(t,"input_schema")!==void 0)return"anthropic";if(d(t,"inputSchema")!==void 0)return"mcp";if(d(t,"parameters")!==void 0)return"gemini";if(typeof d(t,"name")=="string")return"canonical";f("ERR_TOOL_FORMAT","Unable to detect the tool definition format")}function ze(t,e){if(e==="openai-chat"){let n=d(t,"function");return y(n)||f("ERR_TOOL_DEFINITION","OpenAI Chat tool.function must be an object"),n}return t}function we(t,e){return e==="mcp"||e==="canonical"?d(t,"inputSchema")===void 0?d(t,"schema"):d(t,"inputSchema"):e==="anthropic"?d(t,"input_schema"):d(t,"parameters")}function ke(t,e){if(e==="mcp"||e==="canonical")return d(t,"outputSchema")}function Ne(t){for(let e of["__",".","/"]){let n=t.indexOf(e);if(n>0)return t.slice(0,n)}return""}function j(t,e={}){y(t)||f("ERR_TOOL_DEFINITION","Tool definition must be an object"),y(e)||f("ERR_TOOL_ROUTER_OPTION","Normalization options must be an object");let n=Ae(e.format)||Q(t),o=ze(t,n),i=N(d(o,"name"));i.length===0&&f("ERR_TOOL_NAME","Tool name must be a non-empty string"),i.length>512&&f("ERR_TOOL_NAME","Tool name must not exceed 512 characters");let s=N(d(o,"description")),a=N(d(t,"namespace"))||N(d(o,"namespace"))||Ne(i),l=N(d(t,"id"))||N(d(o,"id"))||(a?`${a}:${i}`:i),m=w(d(t,"tags")).concat(w(d(o,"tags"))),g=w(d(t,"aliases")).concat(w(d(o,"aliases")));return Object.freeze({aliases:Object.freeze(Array.from(new Set(g))),description:s,format:n,id:l,inputSchema:we(o,n),name:i,namespace:a,original:t,outputSchema:ke(o,n),tags:Object.freeze(Array.from(new Set(m)))})}function Ie(t){if(Array.isArray(t))return t;y(t)||f("ERR_TOOL_CATALOG","Tool catalog must be an array or provider envelope");let e=d(t,"tools");if(Array.isArray(e))return e;let n=d(t,"functionDeclarations");if(Array.isArray(n))return n;f("ERR_TOOL_CATALOG","Tool catalog envelope must contain tools or functionDeclarations")}function v(t,e={}){y(e)||f("ERR_TOOL_ROUTER_OPTION","Normalization options must be an object");let n=Ie(t),o=b(e.maxTools,1e5,1e6,"maxTools");n.length>o&&f("ERR_TOOL_CATALOG_SIZE",`Tool catalog exceeds maxTools (${o})`);let i=[];for(let s=0;s<n.length;s++){let c=Object.getOwnPropertyDescriptor(n,String(s));if(!c||!Object.prototype.hasOwnProperty.call(c,"value"))continue;let a=c.value;if(y(a)&&Array.isArray(d(a,"functionDeclarations"))){let r=d(a,"functionDeclarations");r.length>o-i.length&&f("ERR_TOOL_CATALOG_SIZE",`Tool catalog exceeds maxTools (${o})`);for(let l=0;l<r.length;l++){let m=Object.getOwnPropertyDescriptor(r,String(l));m&&Object.prototype.hasOwnProperty.call(m,"value")&&i.push(j(m.value,{format:"gemini"}))}}else i.push(j(a,e))}return i}var je=new Set(["title","description","$comment","format","pattern","const"]),ae=new Set(["enum","examples","required"]);function ce(t,e={}){if(!y(t)&&typeof t!="boolean")return"";let n=e.maxSchemaDepth===void 0?24:e.maxSchemaDepth,o=e.maxSchemaNodes===void 0?1e4:e.maxSchemaNodes,i=e.maxTextLength===void 0?65536:e.maxTextLength,s=new WeakSet,c=[],a=0,r=0;function l(){a+=1,a>o&&f("ERR_TOOL_SCHEMA_SIZE",`Tool schema exceeds maxSchemaNodes (${o})`)}function m(h){if(typeof h!="string"&&typeof h!="number")return;let u=String(h);if(u.length===0||r>=i)return;let T=i-r,_=u.slice(0,T);c.push(_),r+=_.length+1}function g(h,u,T){if(u>n&&f("ERR_TOOL_SCHEMA_DEPTH",`Tool schema exceeds maxSchemaDepth (${n})`),l(),!y(h)||s.has(h))return;if(s.add(h),Array.isArray(h)){h.length>o-a&&f("ERR_TOOL_SCHEMA_SIZE",`Tool schema exceeds maxSchemaNodes (${o})`);for(let p=0;p<h.length;p++){let O=Object.getOwnPropertyDescriptor(h,String(p));!O||!Object.prototype.hasOwnProperty.call(O,"value")||(ae.has(T)?(l(),m(O.value)):g(O.value,u+1,T))}return}let _=P(h);_.length>o-a&&f("ERR_TOOL_SCHEMA_SIZE",`Tool schema exceeds maxSchemaNodes (${o})`);for(let[p,O]of _)(T==="properties"||T==="$defs"||T==="definitions")&&m(p),je.has(p)?(l(),m(O)):(ae.has(p)&&Array.isArray(O),g(O,u+1,p))}return g(t,0,""),c.join(" ")}function ee(t,e={}){let n=e.maxDepth===void 0?64:e.maxDepth,o=e.maxNodes===void 0?1e5:e.maxNodes,i=new WeakSet,s=0;function c(r){return JSON.stringify(r).length}function a(r,l){if(l>n&&f("ERR_TOOL_VALUE_DEPTH",`Tool definition exceeds maxDepth (${n})`),s+=1,s>o&&f("ERR_TOOL_VALUE_SIZE",`Tool definition exceeds maxNodes (${o})`),r===null)return 4;if(typeof r=="string")return c(r);if(typeof r=="number")return Number.isFinite(r)?String(r).length:4;if(typeof r=="boolean")return r?4:5;if(typeof r=="bigint"&&f("ERR_TOOL_VALUE_TYPE","Tool definition cannot contain bigint values"),typeof r=="undefined"||typeof r=="function"||typeof r=="symbol")return 0;i.has(r)&&f("ERR_TOOL_CYCLIC","Tool definition must be JSON-serializable"),i.add(r);let m=2,g=0;if(Array.isArray(r)){r.length>o-s&&f("ERR_TOOL_VALUE_SIZE",`Tool definition exceeds maxNodes (${o})`);for(let h=0;h<r.length;h++){let u=Object.getOwnPropertyDescriptor(r,String(h)),T=u&&Object.prototype.hasOwnProperty.call(u,"value")?u.value:null;m+=a(T,l+1)+(g>0?1:0),g+=1}}else{let h=P(r);h.length>o-s&&f("ERR_TOOL_VALUE_SIZE",`Tool definition exceeds maxNodes (${o})`);for(let[u,T]of h){let _=a(T,l+1);_===0&&(T===void 0||typeof T=="function"||typeof T=="symbol")||(m+=c(u)+1+_+(g>0?1:0),g+=1)}}return i.delete(r),m}return Math.max(1,Math.ceil(a(t,0)/4))}var Me=/[\u0300-\u036f]/g,De=/([\p{Ll}\p{N}])([\p{Lu}])/gu,Fe=/([\p{Lu}]+)([\p{Lu}][\p{Ll}])/gu,$e=/[\p{L}\p{N}]+/gu,fe=/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/u;function q(t){if(typeof t!="string")return"";let e=t;return typeof e.normalize=="function"&&(e=e.normalize("NFKD")),e.replace(Me,"").replace(Fe,"$1 $2").replace(De,"$1 $2").toLocaleLowerCase("en-US")}function S(t){return q(t).replace(/[^\p{L}\p{N}]+/gu,"")}function k(t,e={}){let n=e.maxTokenLength===void 0?64:e.maxTokenLength;(!Number.isInteger(n)||n<2||n>256)&&f("ERR_TOOL_ROUTER_OPTION","maxTokenLength must be an integer between 2 and 256");let i=q(t).match($e)||[],s=[];for(let c of i){let a=c.slice(0,n);if((a.length>1||fe.test(a))&&s.push(a),a.length>2&&fe.test(a)){let r=Array.from(a);for(let l=0;l<r.length-1;l++)s.push(`${r[l]}${r[l+1]}`)}}return s}function Z(t){let e=Array.from(` ${t} `),n=[];for(let o=0;o<=e.length-3;o++)n.push(e.slice(o,o+3).join(""));return n}function le(t,e,n){let o=Array.from(t),i=Array.from(e);if(Math.abs(o.length-i.length)>n)return n+1;if(o.length===0)return Math.min(i.length,n+1);if(i.length===0)return Math.min(o.length,n+1);let s=new Array(i.length+1);for(let c=0;c<=i.length;c++)s[c]=c;for(let c=1;c<=o.length;c++){let a=new Array(i.length+1);a[0]=c;let r=a[0];for(let l=1;l<=i.length;l++){let m=s[l-1]+(o[c-1]===i[l-1]?0:1),g=a[l-1]+1,h=s[l]+1;a[l]=Math.min(m,g,h),r=Math.min(r,a[l])}if(r>n)return n+1;s=a}return s[i.length]}var M=Object.freeze(["name","namespace","aliases","tags","description","schema"]),me=Object.freeze({aliases:7,description:2,name:10,namespace:8,schema:1,tags:5}),Ue=Object.freeze([["cancel","close","delete","remove"],["create","add","make","new","open","schedule"],["download","fetch","get","read","retrieve"],["find","discover","look","lookup","query","search"],["list","browse","show"],["send","deliver","post","publish"],["update","change","edit","modify","set"],["available","availability","free"],["conversation","conversations","message","messages"],["document","documents","file","files","page","pages"]]);function Ce(t){let e=Object.create(null);for(let n of M){let o=t&&Object.prototype.hasOwnProperty.call(t,n)?t[n]:me[n];e[n]=I(o,me[n],0,100,`fieldWeights.${n}`)}return Object.freeze(e)}function Pe(t){t!==void 0&&t!==!1&&!y(t)&&f("ERR_TOOL_ROUTER_OPTION","synonyms must be false or an object of string arrays");let e=new Map;function n(i){let s=Array.from(new Set(i.filter(c=>typeof c=="string"&&c.length>1)));for(let c of s){let a=e.get(c);a||(a=new Set,e.set(c,a));for(let r of s)r!==c&&a.add(r)}}if(t!==!1)for(let i of Ue)n(i);if(y(t))for(let[i,s]of P(t)){let c=k(i)[0];if(!c)continue;typeof s!="string"&&!Array.isArray(s)&&f("ERR_TOOL_ROUTER_OPTION",`synonyms.${i} must be a string or array of strings`);let a=[];for(let r of w(typeof s=="string"?[s]:s))a.push(...k(r));n([c,...a])}let o=Object.create(null);for(let[i,s]of e)o[i]=Object.freeze(Array.from(s));return Object.freeze(o)}function ve(t={}){(t===null||typeof t!="object")&&f("ERR_TOOL_ROUTER_OPTION","Router options must be an object");let e=t.tokenizer===void 0?k:t.tokenizer;typeof e!="function"&&f("ERR_TOOL_ROUTER_OPTION","tokenizer must be a function");let n=t.onDuplicate===void 0?"error":t.onDuplicate;return n!=="error"&&n!=="replace"&&f("ERR_TOOL_ROUTER_OPTION","onDuplicate must be error or replace"),Object.freeze({b:I(t.b,.75,0,1,"b"),fieldWeights:Ce(t.fieldWeights),format:t.format,fuzzy:t.fuzzy===void 0?!0:!!t.fuzzy,k1:I(t.k1,1.2,.1,5,"k1"),maxExpansions:b(t.maxExpansions,12,100,"maxExpansions"),maxQueryLength:b(t.maxQueryLength,4096,65536,"maxQueryLength"),maxSchemaDepth:b(t.maxSchemaDepth,24,256,"maxSchemaDepth"),maxSchemaNodes:b(t.maxSchemaNodes,1e4,1e6,"maxSchemaNodes"),maxTextLength:b(t.maxTextLength,65536,1e6,"maxTextLength"),maxTools:b(t.maxTools,1e5,1e6,"maxTools"),minFuzzyLength:b(t.minFuzzyLength,4,32,"minFuzzyLength"),onDuplicate:n,synonyms:Pe(t.synonyms),tokenizer:e})}function de(t){let e=new Map;for(let n of t)e.set(n,(e.get(n)||0)+1);return e}function qe(t){return`${t} ${S(t)}`}function Ze(t){let e=k(t).filter(o=>o.length>0),n=new Set;if(e.length>1){n.add(e.map(o=>Array.from(o)[0]).join(""));for(let o=0;o<e.length-1;o++)for(let i=o+2;i<=Math.min(e.length,o+6);i++)n.add(e.slice(o,i).map(s=>Array.from(s)[0]).join(""))}return n}function Ye(t){let e=[],n=t.match(/\b[A-Z][A-Z0-9]{1,7}\b/g)||[];for(let o of n)e.push(`acr:${o.toLocaleLowerCase("en-US")}`);return e}function V(t){if(t===void 0)return;let e=typeof t=="string"?[t]:t;return Array.isArray(e)||f("ERR_TOOL_ROUTER_OPTION","Search filters must be strings or arrays of strings"),new Set(w(e,1e4))}function ue(t){let e=V(t.ids),n=V(t.namespaces),o=V(t.formats),i=V(t.tags),s=t.filter;return s!==void 0&&typeof s!="function"&&f("ERR_TOOL_ROUTER_OPTION","filter must be a function"),c=>{if(e&&!e.has(c.id)||n&&!n.has(c.namespace)||o&&!o.has(c.format))return!1;if(i){for(let a of i)if(!c.tags.includes(a))return!1}return s===void 0||!!s(c)}}function te(t,e,n,o,i,s=!1){return Object.freeze({estimatedTokens:t.estimatedTokens,id:t.record.id,matchedFields:Object.freeze(Array.from(o).sort()),matchedTerms:Object.freeze(Array.from(n).sort()),name:t.record.name,pinned:s,queryCoverage:i,record:t.record,score:e===null?null:Number(e.toFixed(6)),tool:t.record.original})}var $=class t{constructor(e=[],n={}){this._options=ve(n),this._documents=new Map,this._postings=new Map,this._prefixes=new Map,this._trigrams=new Map,this._fieldTotals=Object.create(null);for(let o of M)this._fieldTotals[o]=0;this._estimatedTokens=0,!(Array.isArray(e)&&e.length===0)&&this.add(e)}get size(){return this._documents.size}get options(){return this._options}add(e,n={}){let o=y(e)&&(Array.isArray(d(e,"tools"))||Array.isArray(d(e,"functionDeclarations")))||Array.isArray(e)?e:[e],s=v(o,{format:n.format===void 0?this._options.format:n.format,maxTools:this._options.maxTools}).map(r=>this._prepare(r)),c=new Set;for(let r of s)c.has(r.record.id)&&f("ERR_TOOL_DUPLICATE",`Duplicate tool id in batch: ${r.record.id}`),c.add(r.record.id),this._documents.has(r.record.id)&&this._options.onDuplicate==="error"&&f("ERR_TOOL_DUPLICATE",`Tool id already exists: ${r.record.id}`);let a=this._documents.size;for(let r of c)this._documents.has(r)||(a+=1);a>this._options.maxTools&&f("ERR_TOOL_CATALOG_SIZE",`Tool catalog exceeds maxTools (${this._options.maxTools})`);for(let r of s)this._documents.has(r.record.id)&&this._removeDocument(r.record.id),this._addDocument(r);return this}remove(e){let n=Array.isArray(e)?e:[e],o=0;for(let i of n){typeof i!="string"&&f("ERR_TOOL_NAME","Tool id must be a string");let s=this._resolveDocument(i);s&&this._removeDocument(s.record.id)&&(o+=1)}return o}replace(e,n={}){let o=new t([],this._options);return o.add(e,n),this._documents=o._documents,this._postings=o._postings,this._prefixes=o._prefixes,this._trigrams=o._trigrams,this._fieldTotals=o._fieldTotals,this._estimatedTokens=o._estimatedTokens,this}clear(){this._documents.clear(),this._postings.clear(),this._prefixes.clear(),this._trigrams.clear();for(let e of M)this._fieldTotals[e]=0;return this._estimatedTokens=0,this}has(e){return!!this._resolveDocument(e)}get(e){let n=this._resolveDocument(e);return n?n.record:void 0}list(){return Array.from(this._documents.values(),e=>e.record)}stats(){return Object.freeze({estimatedTokens:this._estimatedTokens,fields:Object.freeze({...this._fieldTotals}),terms:this._postings.size,tools:this.size})}search(e,n={}){typeof e!="string"&&f("ERR_TOOL_QUERY","Search query must be a string"),e.length>this._options.maxQueryLength&&f("ERR_TOOL_QUERY_SIZE",`Search query exceeds maxQueryLength (${this._options.maxQueryLength})`);let o=b(n.limit,5,1e3,"limit"),i=I(n.minScore,.01,0,Number.MAX_SAFE_INTEGER,"minScore"),s=this._tokenize(e).concat(Ye(e));if(s.length===0||this.size===0)return[];let c=de(s),a=new Map,r=ue(n),l=new Map;for(let[_,p]of c){let O=this._expand(_),x=new Map;for(let[E,A]of O){let R=this._postings.get(E);if(!R)continue;let D=R.size,he=Math.log(1+(this.size-D+.5)/(D+.5));for(let[C,pe]of R){let B=this._documents.get(C);if(!B)continue;let Y=l.get(C);if(Y===void 0&&(Y=r(B.record),l.set(C,Y)),!Y)continue;let W=0,J=[];for(let z of M){let re=pe[z]||0;if(re===0||this._options.fieldWeights[z]===0)continue;let ge=this._fieldTotals[z]/Math.max(1,this.size),Te=1-this._options.b+this._options.b*(B.fieldLengths[z]/ge);W+=this._options.fieldWeights[z]*re/Te,J.push(z)}if(W===0)continue;let _e=W*(this._options.k1+1)/(W+this._options.k1),K=he*_e*A*(1+Math.log(p)),L=x.get(C);if(L||(L={contribution:0,fields:new Set,terms:new Set},x.set(C,L)),K>L.contribution)L.contribution=K,L.fields=new Set(J);else if(K===L.contribution)for(let z of J)L.fields.add(z);L.terms.add(E)}}for(let[E,A]of x){let R=a.get(E);R||(R={fields:new Set,queryTerms:new Set,score:0,terms:new Set},a.set(E,R)),R.score+=A.contribution,R.queryTerms.add(_);for(let D of A.terms)R.terms.add(D);for(let D of A.fields)R.fields.add(D)}}let m=S(e),g=[];for(let[_,p]of a){let O=this._documents.get(_),x=p.score;m&&O.identifiers.has(m)?x+=12:m&&O.identifiersText.includes(m)&&(x+=2);for(let A of c.keys())O.nameTokens.has(A)&&(x+=1.5),O.namespaceTokens.has(A)&&(x+=2);let E=p.queryTerms.size/c.size;x=x*(.65+.35*E)+E,!(x<i)&&g.push({document:O,score:x,state:p,coverage:E})}g.sort((_,p)=>p.score-_.score||_.document.record.name.localeCompare(p.document.record.name)||_.document.record.id.localeCompare(p.document.record.id));let h=[],u=0,T=n.maxEstimatedTokens===void 0?1/0:I(n.maxEstimatedTokens,1/0,1,Number.MAX_SAFE_INTEGER,"maxEstimatedTokens");for(let _ of g){if(h.length>=o)break;u+_.document.estimatedTokens>T||(h.push(te(_.document,_.score,_.state.terms,_.state.fields,Number(_.coverage.toFixed(6)))),u+=_.document.estimatedTokens)}return h}select(e,n={}){return this.search(e,n).map(o=>o.tool)}route(e,n={}){let o=b(n.maxTools,5,1e3,"maxTools"),i=n.maxEstimatedTokens===void 0?1/0:I(n.maxEstimatedTokens,1/0,1,Number.MAX_SAFE_INTEGER,"maxEstimatedTokens"),s=[],c=new Set,a=0,r=!1,l=n.pinned===void 0?[]:typeof n.pinned=="string"?[n.pinned]:n.pinned;Array.isArray(l)||f("ERR_TOOL_ROUTER_OPTION","pinned must be a string or array of strings");for(let u of l){if(s.length>=o)break;let T=this._resolveDocument(u);!T||c.has(T.record.id)||(s.push(te(T,null,new Set,new Set,0,!0)),c.add(T.record.id),a+=T.estimatedTokens,a>i&&(r=!0))}let m=this.search(e,{...n,limit:Math.min(1e3,Math.max(o,o*4)),maxEstimatedTokens:void 0});for(let u of m){if(s.length>=o)break;c.has(u.id)||a+u.estimatedTokens>i||(s.push(u),c.add(u.id),a+=u.estimatedTokens)}let g=n.fallback===void 0?"none":n.fallback;if(["none","first","all"].includes(g)||f("ERR_TOOL_ROUTER_OPTION","fallback must be none, first, or all"),s.length===0&&g!=="none"){let u=ue(n),T=Array.from(this._documents.values()).filter(p=>u(p.record)).sort((p,O)=>p.record.id.localeCompare(O.record.id)),_=g==="first"?1:o;for(let p of T){if(s.length>=_)break;a+p.estimatedTokens>i||(s.push(te(p,null,new Set,new Set,0)),c.add(p.record.id),a+=p.estimatedTokens)}}let h=this._estimatedTokens===0?0:Math.max(0,1-a/this._estimatedTokens);return Object.freeze({budgetExceeded:r,catalogEstimatedTokens:this._estimatedTokens,catalogSize:this.size,estimatedTokens:a,matches:Object.freeze(s),records:Object.freeze(s.map(u=>u.record)),selectedCount:s.length,tokenReduction:Number(h.toFixed(6)),tools:Object.freeze(s.map(u=>u.tool))})}_prepare(e){let n=ce(e.inputSchema,this._options),o={aliases:e.aliases.join(" "),description:e.description,name:qe(e.name),namespace:e.namespace,schema:n,tags:e.tags.join(" ")},i=Object.create(null),s=Object.create(null),c=new Set([S(e.name)]);for(let r of e.aliases)c.add(S(r));e.namespace&&c.add(S(`${e.namespace} ${e.name}`)),c.delete("");let a=0;for(let r of M){a+=o[r].length,(o[r].length>this._options.maxTextLength||a>this._options.maxTextLength*2)&&f("ERR_TOOL_TEXT_SIZE",`Tool ${e.id} exceeds maxTextLength (${this._options.maxTextLength})`);let l=this._tokenize(o[r]);i[r]=de(l),s[r]=l.length}for(let r of Ze(e.name)){let l=`acr:${r}`;i.name.set(l,(i.name.get(l)||0)+1),s.name+=1}return{estimatedTokens:ee(e.original),fieldLengths:s,fieldTokens:i,identifiers:c,identifiersText:Array.from(c).join(" "),nameTokens:new Set(this._tokenize(e.name)),namespaceTokens:new Set(this._tokenize(e.namespace)),record:e}}_tokenize(e){let n=this._options.tokenizer(e);Array.isArray(n)||f("ERR_TOOL_TOKENIZER","tokenizer must return an array of strings");let o=[];for(let i of n)typeof i!="string"&&f("ERR_TOOL_TOKENIZER","tokenizer results must be strings"),i.length>0&&i.length<=256&&o.push(i);return o}_addDocument(e){this._documents.set(e.record.id,e),this._estimatedTokens+=e.estimatedTokens;for(let n of M){this._fieldTotals[n]+=e.fieldLengths[n];for(let[o,i]of e.fieldTokens[n]){let s=this._postings.get(o),c=!s;s||(s=new Map,this._postings.set(o,s));let a=s.get(e.record.id);a||(a=Object.create(null),s.set(e.record.id,a)),a[n]=i,c&&this._addLexiconTerm(o)}}}_removeDocument(e){let n=this._documents.get(e);if(!n)return!1;this._documents.delete(e),this._estimatedTokens-=n.estimatedTokens;for(let o of M){this._fieldTotals[o]-=n.fieldLengths[o];for(let i of n.fieldTokens[o].keys()){let s=this._postings.get(i);s&&(s.delete(e),s.size===0&&(this._postings.delete(i),this._removeLexiconTerm(i)))}}return!0}_addLexiconTerm(e){let n=Math.min(8,e.length);for(let o=2;o<=n;o++){let i=e.slice(0,o),s=this._prefixes.get(i);s||(s=new Set,this._prefixes.set(i,s)),s.add(e)}for(let o of new Set(Z(e))){let i=this._trigrams.get(o);i||(i=new Set,this._trigrams.set(o,i)),i.add(e)}}_removeLexiconTerm(e){let n=Math.min(8,e.length);for(let o=2;o<=n;o++){let i=e.slice(0,o),s=this._prefixes.get(i);s&&(s.delete(e),s.size===0&&this._prefixes.delete(i))}for(let o of new Set(Z(e))){let i=this._trigrams.get(o);i&&(i.delete(e),i.size===0&&this._trigrams.delete(o))}}_expand(e){let n=new Map;this._addLiteralExpansions(n,e,1);let o=this._options.synonyms[e];if(o)for(let r of o)this._addLiteralExpansions(n,r,.72);if(n.size>0||!this._options.fuzzy||e.length<this._options.minFuzzyLength)return new Map(Array.from(n).sort((r,l)=>l[1]-r[1]||r[0].localeCompare(l[0])).slice(0,this._options.maxExpansions));let i=new Set(Z(e)),s=new Map;for(let r of i){let l=this._trigrams.get(r);if(l)for(let m of l)s.set(m,(s.get(m)||0)+1)}let c=[],a=e.length<=5?1:2;for(let[r,l]of s){let m=new Set(Z(r)),g=l/(i.size+m.size-l);if(g<.25)continue;let h=le(e,r,a);if(h>a)continue;let u=1-h/Math.max(e.length,r.length);c.push({term:r,weight:.55+.25*u,similarity:g})}c.sort((r,l)=>l.similarity-r.similarity||l.weight-r.weight||r.term.localeCompare(l.term));for(let r of c.slice(0,this._options.maxExpansions))n.set(r.term,r.weight);return n}_addLiteralExpansions(e,n,o){if(this._postings.has(n)&&e.set(n,Math.max(o,e.get(n)||0)),n.length<3)return;let i=n.slice(0,Math.min(8,n.length)),s=this._prefixes.get(i);if(!s)return;let c=Array.from(s).filter(a=>a.startsWith(n)&&a!==n).sort((a,r)=>Math.abs(a.length-n.length)-Math.abs(r.length-n.length)||a.localeCompare(r));for(let a of c.slice(0,this._options.maxExpansions))e.set(a,Math.max(o*.78,e.get(a)||0))}_resolveDocument(e){if(typeof e!="string")return;let n=this._documents.get(e);if(n)return n;let o;for(let i of this._documents.values())i.record.name===e&&(!o||i.record.id.localeCompare(o.record.id)<0)&&(o=i);return o}};function H(t=[],e={}){return new $(t,e)}function ne(t,e,n={}){let o=n.router||{},i={...n};return delete i.router,H(t,o).route(e,i)}function oe(t){return ee(t)}function ie(t){return j(t).original}var U=Object.freeze({additionalProperties:!1,properties:{limit:{description:"Maximum number of matching tool summaries to return.",maximum:25,minimum:1,type:"integer"},query:{description:"Capability, action, service, or resource needed for the task.",minLength:1,type:"string"}},required:["query"],type:"object"});function We(t,e,n){if(t==="openai-responses")return{type:"function",name:e,description:n,parameters:U};if(t==="openai-chat")return{type:"function",function:{name:e,description:n,parameters:U}};if(t==="anthropic")return{name:e,description:n,input_schema:U};if(t==="gemini")return{name:e,description:n,parameters:U};if(t==="mcp")return{name:e,description:n,inputSchema:U};if(t==="canonical")return{name:e,description:n,inputSchema:U};f("ERR_TOOL_FORMAT",`Unsupported search tool target: ${String(t)}`)}function se(t,e={}){(!t||typeof t.search!="function")&&f("ERR_TOOL_ROUTER","createToolSearch requires a ToolRouter instance");let n=e.target===void 0?"canonical":e.target,o=e.name===void 0?"search_tools":e.name,i=e.description===void 0?"Find the smallest set of available tools relevant to a capability or task.":e.description;(typeof o!="string"||o.length===0)&&f("ERR_TOOL_NAME","Search tool name must be a non-empty string"),typeof i!="string"&&f("ERR_TOOL_DEFINITION","Search tool description must be a string");let s=e.limit===void 0?5:e.limit;(!Number.isInteger(s)||s<1||s>25)&&f("ERR_TOOL_ROUTER_OPTION","Search tool limit must be an integer between 1 and 25");let c=We(n,o,i);return Object.freeze({definition:c,execute(a){(!a||typeof a!="object"||typeof a.query!="string"||a.query.length===0)&&f("ERR_TOOL_QUERY","Search tool input.query must be a non-empty string");let r=a.limit===void 0?s:a.limit;(!Number.isInteger(r)||r<1||r>25)&&f("ERR_TOOL_QUERY","Search tool input.limit must be an integer between 1 and 25");let l=t.search(a.query,{limit:r});return Object.freeze({catalogSize:t.size,query:a.query,tools:Object.freeze(l.map(m=>Object.freeze({description:m.record.description,id:m.id,name:m.name,namespace:m.record.namespace||void 0,score:m.score,tags:m.record.tags})))})}})}var Ge=Object.freeze({TOOL_FORMATS:G,ToolRouter:$,ToolRouterError:F,createToolRouter:H,createToolSearch:se,defineTool:ie,detectToolFormat:Q,estimateToolTokens:oe,normalizeIdentifier:S,normalizeText:q,normalizeTool:j,normalizeTools:v,routeTools:ne,tokenize:k});return Ee(Qe);})();
|
|
3
3
|
//# sourceMappingURL=index.min.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Data flow
|
|
4
|
+
|
|
5
|
+
1. An adapter detects the provider shape and creates an immutable normalized
|
|
6
|
+
record while retaining the original definition by reference.
|
|
7
|
+
2. Schema text is extracted with bounded, getter-free traversal.
|
|
8
|
+
3. Names, namespaces, aliases, tags, descriptions, and schema text are
|
|
9
|
+
tokenized into weighted field postings.
|
|
10
|
+
4. A query is normalized, uppercase acronyms are identified, and action
|
|
11
|
+
synonyms are expanded with lower weight than literal terms.
|
|
12
|
+
5. BM25F-style scores are calculated from current document frequencies and
|
|
13
|
+
field lengths.
|
|
14
|
+
6. Literal name and namespace matches receive explicit intent bonuses.
|
|
15
|
+
7. Results are ordered deterministically and constrained by count and estimated
|
|
16
|
+
token budgets.
|
|
17
|
+
|
|
18
|
+
## Modules
|
|
19
|
+
|
|
20
|
+
| Module | Responsibility |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `adapters.js` | provider detection and immutable normalized records |
|
|
23
|
+
| `tokenize.js` | Unicode normalization, identifier splitting, trigrams, edit distance |
|
|
24
|
+
| `schema.js` | bounded schema text collection and JSON token estimation |
|
|
25
|
+
| `router.js` | postings, incremental catalog updates, ranking, filters, budgets |
|
|
26
|
+
| `search-tool.js` | provider-shaped catalog discovery function |
|
|
27
|
+
| `safe.js` | own-data-property reads and option validation |
|
|
28
|
+
| `errors.js` | stable typed error codes |
|
|
29
|
+
|
|
30
|
+
## Index structure
|
|
31
|
+
|
|
32
|
+
The router keeps:
|
|
33
|
+
|
|
34
|
+
- a `Map` from tool ID to normalized document;
|
|
35
|
+
- a term posting `Map` from term to document field frequencies;
|
|
36
|
+
- bounded prefix and trigram maps for completion and typo candidates;
|
|
37
|
+
- current field-length totals for ranking normalization;
|
|
38
|
+
- cached per-tool token estimates for route budgets.
|
|
39
|
+
|
|
40
|
+
Incremental removal deletes postings, lexicon entries, field totals, and token
|
|
41
|
+
estimates. Replacement is transactional: all incoming tools are normalized and
|
|
42
|
+
prepared before the live index changes.
|
|
43
|
+
|
|
44
|
+
## Ranking semantics
|
|
45
|
+
|
|
46
|
+
Each original query term is a disjunction. Literal, prefix, synonym, and fuzzy
|
|
47
|
+
expansions compete, and only the strongest contribution for that query term is
|
|
48
|
+
added to a document. This prevents a document from receiving several votes
|
|
49
|
+
because it happens to contain multiple words from one synonym group.
|
|
50
|
+
|
|
51
|
+
Field weights are inputs to a BM25F-style normalized term frequency. Exact
|
|
52
|
+
whole identifiers receive a larger bonus; literal name and namespace tokens
|
|
53
|
+
receive smaller bonuses. Query coverage moderates partial matches.
|
|
54
|
+
|
|
55
|
+
Fuzzy candidates are drawn from a trigram index before bounded edit distance is
|
|
56
|
+
calculated. The implementation never compares every query token against every
|
|
57
|
+
catalog term.
|
|
58
|
+
|
|
59
|
+
## Provider boundary
|
|
60
|
+
|
|
61
|
+
Adapters identify where each provider stores name, description, and input
|
|
62
|
+
schema. They do not translate schemas. Returning the original object avoids
|
|
63
|
+
subtle wire incompatibilities and lets applications continue using their
|
|
64
|
+
current SDK and type definitions.
|
|
65
|
+
|
|
66
|
+
## Security design
|
|
67
|
+
|
|
68
|
+
Untrusted keys never become properties on ordinary internal objects. Schema
|
|
69
|
+
traversal uses own property descriptors and skips accessors. Dangerous
|
|
70
|
+
prototype keys are ignored. Weak sets detect cycles, and all potentially large
|
|
71
|
+
dimensions have defaults and absolute option bounds.
|
|
72
|
+
|
|
73
|
+
Search regular expressions are fixed library constants with linear behavior.
|
|
74
|
+
Fuzzy matching uses bounded dynamic programming over tokens limited to 256
|
|
75
|
+
characters.
|
|
76
|
+
|
|
77
|
+
## Complexity
|
|
78
|
+
|
|
79
|
+
- Build: proportional to indexed tokens and schema nodes.
|
|
80
|
+
- Literal query: proportional to postings for matching terms.
|
|
81
|
+
- Fuzzy fallback: proportional to trigram candidates, not the complete term
|
|
82
|
+
dictionary.
|
|
83
|
+
- Add/remove: proportional to tokens in the affected tools.
|
|
84
|
+
|
|
85
|
+
Very large catalogs should benchmark memory and latency with their real schema
|
|
86
|
+
distribution. A future precompiled-index format is tracked in TODO.md.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Evaluation and Benchmark Methodology
|
|
2
|
+
|
|
3
|
+
The benchmark contains two independent checks:
|
|
4
|
+
|
|
5
|
+
1. a transparent 30-tool, 30-intent corpus that reports recall at 1, recall at
|
|
6
|
+
5, misses, and estimated schema-token reduction;
|
|
7
|
+
2. a synthetic 10,000-tool catalog that reports index build time, heap change,
|
|
8
|
+
and p50 and p95 route latency.
|
|
9
|
+
|
|
10
|
+
Run the checked-in corpus:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm ci
|
|
14
|
+
npm run benchmark
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The corpus is intentionally readable and should be replaced or extended with
|
|
18
|
+
representative production tools before selecting thresholds. Synthetic
|
|
19
|
+
catalog latency is a regression baseline, not a promise for every vocabulary,
|
|
20
|
+
schema size, CPU, or runtime.
|
|
21
|
+
|
|
22
|
+
Report the commit, Node.js version, CPU, raw JSON output, catalog shape, and
|
|
23
|
+
query set when sharing results. Do not tune against a hidden test set and then
|
|
24
|
+
present the same set as independent evaluation.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Compatibility
|
|
2
|
+
|
|
3
|
+
## Runtime matrix
|
|
4
|
+
|
|
5
|
+
| Runtime | Support |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Node.js 14.17, 16, 18, 20, 22, 24 | CI artifact smoke test |
|
|
8
|
+
| Browsers with ES2018 support | IIFE and ESM builds |
|
|
9
|
+
| Deno 1.46 and 2.x | direct ESM runtime test |
|
|
10
|
+
| Bun current | direct ESM runtime test |
|
|
11
|
+
|
|
12
|
+
The package does not use Node.js built-ins at runtime. Browser and edge usage
|
|
13
|
+
does not require a shim.
|
|
14
|
+
|
|
15
|
+
## Module formats
|
|
16
|
+
|
|
17
|
+
- ESM: `dist/index.js`
|
|
18
|
+
- CommonJS: `dist/index.cjs`
|
|
19
|
+
- browser global: `dist/index.min.js` as `StacklineToolRouter`
|
|
20
|
+
- declarations: `.d.ts`, `.d.mts`, and `.d.cts`
|
|
21
|
+
|
|
22
|
+
Package exports select matching runtime and declaration files. `publint` and
|
|
23
|
+
Are the Types Wrong validate the packed artifact.
|
|
24
|
+
|
|
25
|
+
## TypeScript
|
|
26
|
+
|
|
27
|
+
Declarations intentionally avoid syntax newer than TypeScript 3.9. CI installs
|
|
28
|
+
the packed package into clean consumers using TypeScript 3.9, 4.7, 4.9, 5.9,
|
|
29
|
+
6, and current releases.
|
|
30
|
+
|
|
31
|
+
`defineTool<T>` preserves the caller's exact inferred type. `ToolRouter<T>`,
|
|
32
|
+
`ToolMatch<T>`, and `ToolRouteResult<T>` carry that type through selected
|
|
33
|
+
original definitions.
|
|
34
|
+
|
|
35
|
+
## Provider formats
|
|
36
|
+
|
|
37
|
+
Recognition is structural and does not require provider SDK packages. Provider
|
|
38
|
+
schemas remain opaque after bounded text extraction. A schema accepted by one
|
|
39
|
+
provider is not claimed to be accepted by another.
|
|
40
|
+
|
|
41
|
+
## Versioning
|
|
42
|
+
|
|
43
|
+
The 1.x line preserves public method names, normalized record fields, stable
|
|
44
|
+
error codes, original-definition return behavior, and supported module formats.
|
|
45
|
+
Ranking improvements may reorder genuinely ambiguous results and will be
|
|
46
|
+
documented in release notes. Removing a runtime, TypeScript line, or provider
|
|
47
|
+
shape requires a major release.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Provider Integration Guide
|
|
2
|
+
|
|
3
|
+
## OpenAI Responses
|
|
4
|
+
|
|
5
|
+
Responses tools place `name`, `description`, and `parameters` on the outer
|
|
6
|
+
function object. Route the original array and pass `route.tools` directly to
|
|
7
|
+
the existing SDK request. See
|
|
8
|
+
[`examples/openai-responses.mjs`](../examples/openai-responses.mjs).
|
|
9
|
+
|
|
10
|
+
## OpenAI Chat Completions
|
|
11
|
+
|
|
12
|
+
Chat tools wrap the definition in `function`. The router detects that envelope
|
|
13
|
+
and returns the original wrapped objects without conversion. See
|
|
14
|
+
[`examples/openai-chat.mjs`](../examples/openai-chat.mjs).
|
|
15
|
+
|
|
16
|
+
## Anthropic Messages
|
|
17
|
+
|
|
18
|
+
Anthropic uses `input_schema`. No schema rewrite is needed. Route the array and
|
|
19
|
+
place the selected definitions in the Messages request. See
|
|
20
|
+
[`examples/anthropic.mjs`](../examples/anthropic.mjs).
|
|
21
|
+
|
|
22
|
+
## Gemini
|
|
23
|
+
|
|
24
|
+
Pass a `functionDeclarations` envelope or an array of declarations. The
|
|
25
|
+
selected original declarations can be placed back into the provider request.
|
|
26
|
+
See [`examples/gemini.mjs`](../examples/gemini.mjs).
|
|
27
|
+
|
|
28
|
+
## MCP
|
|
29
|
+
|
|
30
|
+
Index the result of connected servers' `tools/list` calls. `createToolSearch`
|
|
31
|
+
can also expose compact discovery as an MCP-shaped function while full schemas
|
|
32
|
+
remain outside the initial model context. See
|
|
33
|
+
[`examples/mcp.mjs`](../examples/mcp.mjs).
|
|
34
|
+
|
|
35
|
+
## Large and changing catalogs
|
|
36
|
+
|
|
37
|
+
Use `add`, `remove`, and `replace` for catalogs that change while the process
|
|
38
|
+
is running. Count and token budgets are independent, so production policy can
|
|
39
|
+
bound both. The package remains a local retrieval layer and never executes a
|
|
40
|
+
tool or calls a provider.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Market Research
|
|
2
|
+
|
|
3
|
+
Research snapshot: 2026-08-20.
|
|
4
|
+
|
|
5
|
+
## Market signal
|
|
6
|
+
|
|
7
|
+
Agent platforms are moving from a few hardcoded functions to catalogs spanning
|
|
8
|
+
many services and MCP servers. The resulting bottleneck is no longer only tool
|
|
9
|
+
execution; it is deciding which definitions should enter model context.
|
|
10
|
+
|
|
11
|
+
Anthropic's advanced tool-use engineering report gives a concrete production
|
|
12
|
+
shape: 58 tools across five servers consumed about 55,000 tokens before a task,
|
|
13
|
+
and its tool-search approach reduced tool-definition token use by 85% while
|
|
14
|
+
improving internal selection evaluations. The report recommends dynamic search
|
|
15
|
+
for catalogs above roughly ten tools or 10,000 definition tokens:
|
|
16
|
+
|
|
17
|
+
https://www.anthropic.com/engineering/advanced-tool-use
|
|
18
|
+
|
|
19
|
+
OpenAI model documentation now lists tool search as a supported Responses API
|
|
20
|
+
capability on current tool-capable models, and the API reference describes both
|
|
21
|
+
hosted and bring-your-own-tool search configuration:
|
|
22
|
+
|
|
23
|
+
- https://developers.openai.com/api/docs/models/gpt-5.4
|
|
24
|
+
- https://developers.openai.com/api/reference/typescript/resources/beta/subresources/responses
|
|
25
|
+
|
|
26
|
+
MCP defines discoverable tool names, descriptions, input schemas, output
|
|
27
|
+
schemas, and annotations. MCP's move toward JSON Schema 2020-12 increases the
|
|
28
|
+
amount and richness of metadata a multi-server client may carry:
|
|
29
|
+
|
|
30
|
+
- https://modelcontextprotocol.io/specification/2025-11-25/server/tools
|
|
31
|
+
- https://modelcontextprotocol.io/seps/2106-json-schema-2020-12
|
|
32
|
+
|
|
33
|
+
The public npm registry also shows that agent infrastructure can become deeply
|
|
34
|
+
transitive. On the research date, registry download endpoints reported large
|
|
35
|
+
monthly use for the official MCP SDK and related adapters. Counts change over
|
|
36
|
+
time and can be checked directly:
|
|
37
|
+
|
|
38
|
+
- https://api.npmjs.org/downloads/point/last-month/%40modelcontextprotocol%2Fsdk
|
|
39
|
+
- https://api.npmjs.org/downloads/point/last-month/%40ai-sdk%2Fmcp
|
|
40
|
+
- https://api.npmjs.org/downloads/point/last-month/%40a2a-js%2Fsdk
|
|
41
|
+
|
|
42
|
+
## Existing approaches
|
|
43
|
+
|
|
44
|
+
Current solutions generally fall into four groups:
|
|
45
|
+
|
|
46
|
+
1. Provider-hosted tool search, which is effective but provider- and
|
|
47
|
+
model-specific.
|
|
48
|
+
2. MCP proxies that sit between a client and servers and require the MCP SDK.
|
|
49
|
+
3. General full-text engines that are capable but do not normalize AI tool
|
|
50
|
+
formats or return routing evidence and budgets.
|
|
51
|
+
4. Embedding/vector retrieval, which adds model calls, storage, latency,
|
|
52
|
+
nondeterminism, and operational cost.
|
|
53
|
+
|
|
54
|
+
The gap is a provider-neutral in-process primitive: accept definitions already
|
|
55
|
+
owned by the application, rank them locally, preserve the original payload,
|
|
56
|
+
and stay small enough to become a transitive dependency.
|
|
57
|
+
|
|
58
|
+
## Product thesis
|
|
59
|
+
|
|
60
|
+
`@stackline/tool-router` targets that gap with:
|
|
61
|
+
|
|
62
|
+
- zero runtime dependencies;
|
|
63
|
+
- no provider SDK or network requirement;
|
|
64
|
+
- original-definition preservation;
|
|
65
|
+
- provider-shape normalization for retrieval only;
|
|
66
|
+
- deterministic lexical ranking with bounded typo handling;
|
|
67
|
+
- incremental catalog updates;
|
|
68
|
+
- token and tool-count budgets;
|
|
69
|
+
- browser, server, edge, Deno, and Bun support.
|
|
70
|
+
|
|
71
|
+
Potential for very large download volume depends on framework adoption and
|
|
72
|
+
transitive use, not only direct installs. No download outcome is guaranteed.
|
|
73
|
+
The package is designed around the characteristics that enable broad adoption:
|
|
74
|
+
a narrow problem, low integration cost, a stable API, no runtime dependency
|
|
75
|
+
risk, and utility across competing AI providers.
|
|
76
|
+
|
|
77
|
+
## Validation baseline
|
|
78
|
+
|
|
79
|
+
The repository's transparent 1.0.0 evaluation contains 30 tools and 30 natural
|
|
80
|
+
language intents covering GitHub, Slack, Drive, Jira, Calendar, Stripe,
|
|
81
|
+
PostgreSQL, Sentry, Grafana, Notion, email, and contacts.
|
|
82
|
+
|
|
83
|
+
Observed on the maintainer workstation:
|
|
84
|
+
|
|
85
|
+
- recall@1: 100%;
|
|
86
|
+
- recall@5: 100%;
|
|
87
|
+
- average estimated token reduction at five selected tools: 85.28%;
|
|
88
|
+
- 10,000-tool synthetic build: about 759 ms;
|
|
89
|
+
- 10,000-tool query: about 67 ms p50 and 109 ms p95.
|
|
90
|
+
|
|
91
|
+
The corpus and generator are in `benchmark/benchmark.mjs`. These are transparent
|
|
92
|
+
engineering baselines, not claims about every catalog. Production evaluation
|
|
93
|
+
must use real tool names, descriptions, aliases, and user requests.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
## Preconditions
|
|
4
|
+
|
|
5
|
+
- Version, changelog, README, docs, and package metadata agree.
|
|
6
|
+
- `npm test` passes from a clean install.
|
|
7
|
+
- `npm run test:attw` passes on the packed artifact.
|
|
8
|
+
- `npm run benchmark` has no unexpected recall regression.
|
|
9
|
+
- `npm run audit:dependencies` passes.
|
|
10
|
+
- The working tree contains only intended release changes.
|
|
11
|
+
|
|
12
|
+
## Artifact-first process
|
|
13
|
+
|
|
14
|
+
1. Run `npm pack --ignore-scripts` once and record its SHA-512 digest.
|
|
15
|
+
2. Publish that tarball to the local Verdaccio registry.
|
|
16
|
+
3. Install the exact Verdaccio version into a clean consumer and execute ESM,
|
|
17
|
+
CommonJS, and TypeScript smoke tests.
|
|
18
|
+
4. Push the reviewed commit and wait for GitHub Actions.
|
|
19
|
+
5. Download the CI artifact and compare its package digest.
|
|
20
|
+
6. Run `publish.yml` with the local tarball's SHA-512 hex digest. The trusted
|
|
21
|
+
workflow rebuilds the reviewed commit, refuses a digest mismatch, and
|
|
22
|
+
publishes through npm OIDC with provenance.
|
|
23
|
+
7. Download the public npm tarball and compare its digest and contents.
|
|
24
|
+
8. Create the signed version tag and GitHub Release with package, checksums,
|
|
25
|
+
SBOM, and documentation archive.
|
|
26
|
+
9. Deploy documentation and verify canonical, sitemap, robots, AI docs, and the
|
|
27
|
+
live browser workbench.
|
|
28
|
+
|
|
29
|
+
Do not rebuild between registries. A release is one source commit and one
|
|
30
|
+
immutable package artifact.
|
|
31
|
+
|
|
32
|
+
## Rollback
|
|
33
|
+
|
|
34
|
+
npm versions are immutable. If a published release is incorrect, deprecate it
|
|
35
|
+
with a precise reason, publish a patch, and move `latest` only after the patch
|
|
36
|
+
passes the full process. Never replace or silently rewrite an existing version.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createToolRouter } from '@stackline/tool-router';
|
|
2
|
+
|
|
3
|
+
const tools = [
|
|
4
|
+
{
|
|
5
|
+
name: 'drive_search_files',
|
|
6
|
+
description: 'Search files in Google Drive.',
|
|
7
|
+
input_schema: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: { query: { type: 'string' } },
|
|
10
|
+
required: ['query']
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'calendar_create_event',
|
|
15
|
+
description: 'Schedule a calendar event with attendees.',
|
|
16
|
+
input_schema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { title: { type: 'string' } },
|
|
19
|
+
required: ['title']
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const prompt = 'Find the Q4 plan in Drive';
|
|
25
|
+
const { tools: selectedTools } = createToolRouter(tools).route(prompt, { maxTools: 3 });
|
|
26
|
+
|
|
27
|
+
console.log({
|
|
28
|
+
model: 'your-model',
|
|
29
|
+
max_tokens: 1024,
|
|
30
|
+
messages: [{ role: 'user', content: prompt }],
|
|
31
|
+
tools: selectedTools
|
|
32
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createToolRouter } from '@stackline/tool-router';
|
|
2
|
+
|
|
3
|
+
const router = createToolRouter([], { onDuplicate: 'replace' });
|
|
4
|
+
|
|
5
|
+
router.add({
|
|
6
|
+
name: 'customers_find',
|
|
7
|
+
description: 'Find a customer by email.',
|
|
8
|
+
inputSchema: { type: 'object', properties: { email: { type: 'string' } } }
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
router.add({
|
|
12
|
+
name: 'customers_find',
|
|
13
|
+
description: 'Find a customer by email or account identifier.',
|
|
14
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' } } }
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const route = router.route('look up the account owner', {
|
|
18
|
+
maxEstimatedTokens: 2_000,
|
|
19
|
+
maxTools: 5,
|
|
20
|
+
pinned: []
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
console.log(route.records.map(({ id, name }) => ({ id, name })));
|
|
24
|
+
console.log({
|
|
25
|
+
selected: route.selectedCount,
|
|
26
|
+
estimatedReduction: route.tokenReduction
|
|
27
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { createToolRouter } from '@stackline/tool-router';
|
|
4
|
+
|
|
5
|
+
const functionDeclarations = [
|
|
6
|
+
declaration('calendar_find_free_time', 'Find shared free time for attendees.'),
|
|
7
|
+
declaration('calendar_create_event', 'Create a calendar event.'),
|
|
8
|
+
declaration('drive_search_files', 'Search Google Drive files.')
|
|
9
|
+
];
|
|
10
|
+
const prompt = 'Find free time for meeting attendees';
|
|
11
|
+
const route = createToolRouter({ functionDeclarations }).route(prompt, { maxTools: 2 });
|
|
12
|
+
|
|
13
|
+
assert.equal(route.records[0].name, 'calendar_find_free_time');
|
|
14
|
+
console.log({ functionDeclarations: route.tools });
|
|
15
|
+
|
|
16
|
+
function declaration(name, description) {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
description,
|
|
20
|
+
parameters: { type: 'object', properties: {} }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { createToolRouter } from '@stackline/tool-router';
|
|
4
|
+
|
|
5
|
+
const tools = Array.from({ length: 1_000 }, (_, index) => ({
|
|
6
|
+
name: `service_${index % 20}_resource_${index}_search`,
|
|
7
|
+
description: `Search resource ${index} records in service ${index % 20}.`,
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: { query: { type: 'string' } }
|
|
11
|
+
}
|
|
12
|
+
}));
|
|
13
|
+
const router = createToolRouter(tools);
|
|
14
|
+
const route = router.route('search resource 742 in service 2', {
|
|
15
|
+
maxEstimatedTokens: 2_000,
|
|
16
|
+
maxTools: 5
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(route.records[0].name, 'service_2_resource_742_search');
|
|
20
|
+
assert.ok(route.tokenReduction > 0.9);
|
|
21
|
+
console.log({
|
|
22
|
+
catalogTools: router.size,
|
|
23
|
+
selectedTools: route.selectedCount,
|
|
24
|
+
tokenReduction: route.tokenReduction
|
|
25
|
+
});
|
package/examples/mcp.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createToolRouter, createToolSearch } from '@stackline/tool-router';
|
|
2
|
+
|
|
3
|
+
const mcpTools = await listToolsFromConnectedServers();
|
|
4
|
+
const router = createToolRouter(mcpTools);
|
|
5
|
+
const discovery = createToolSearch(router, { target: 'mcp', limit: 5 });
|
|
6
|
+
|
|
7
|
+
console.log(discovery.definition);
|
|
8
|
+
console.log(discovery.execute({ query: 'search error logs' }));
|
|
9
|
+
|
|
10
|
+
async function listToolsFromConnectedServers() {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
name: 'sentry_search_errors',
|
|
14
|
+
description: 'Search application errors in Sentry.',
|
|
15
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' } } }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'grafana_query_metrics',
|
|
19
|
+
description: 'Query time-series metrics in Grafana.',
|
|
20
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' } } }
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { createToolRouter } from '@stackline/tool-router';
|
|
4
|
+
|
|
5
|
+
const tools = [
|
|
6
|
+
functionTool('github_search_code', 'Search code in GitHub repositories.'),
|
|
7
|
+
functionTool('github_create_issue', 'Create a GitHub issue.'),
|
|
8
|
+
functionTool('slack_send_message', 'Send a Slack channel message.')
|
|
9
|
+
];
|
|
10
|
+
const prompt = 'Create a GitHub issue for the checkout bug';
|
|
11
|
+
const route = createToolRouter(tools).route(prompt, { maxTools: 2 });
|
|
12
|
+
|
|
13
|
+
assert.equal(route.records[0].name, 'github_create_issue');
|
|
14
|
+
console.log({ messages: [{ role: 'user', content: prompt }], tools: route.tools });
|
|
15
|
+
|
|
16
|
+
function functionTool(name, description) {
|
|
17
|
+
return {
|
|
18
|
+
type: 'function',
|
|
19
|
+
function: {
|
|
20
|
+
name,
|
|
21
|
+
description,
|
|
22
|
+
parameters: { type: 'object', properties: {} }
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createToolRouter } from '@stackline/tool-router';
|
|
2
|
+
|
|
3
|
+
const tools = [
|
|
4
|
+
{
|
|
5
|
+
type: 'function',
|
|
6
|
+
name: 'github_create_issue',
|
|
7
|
+
description: 'Create a GitHub issue in a repository.',
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
repository: { type: 'string' },
|
|
12
|
+
title: { type: 'string' }
|
|
13
|
+
},
|
|
14
|
+
required: ['repository', 'title'],
|
|
15
|
+
additionalProperties: false
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
type: 'function',
|
|
20
|
+
name: 'slack_send_message',
|
|
21
|
+
description: 'Send a message to a Slack channel.',
|
|
22
|
+
parameters: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
channel: { type: 'string' },
|
|
26
|
+
text: { type: 'string' }
|
|
27
|
+
},
|
|
28
|
+
required: ['channel', 'text'],
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const router = createToolRouter(tools);
|
|
35
|
+
const prompt = 'Open an issue for the checkout regression';
|
|
36
|
+
const selected = router.route(prompt, { maxTools: 4 });
|
|
37
|
+
|
|
38
|
+
// Pass selected.tools to the provider SDK without changing the definitions.
|
|
39
|
+
const request = {
|
|
40
|
+
model: 'your-model',
|
|
41
|
+
input: prompt,
|
|
42
|
+
tools: selected.tools
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
console.log(request);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackline/tool-router",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Zero-dependency AI tool discovery and routing for MCP, OpenAI, Anthropic, and Gemini catalogs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
},
|
|
61
61
|
"files": [
|
|
62
62
|
"dist",
|
|
63
|
+
"docs",
|
|
64
|
+
"examples",
|
|
63
65
|
"CHANGELOG.md",
|
|
64
66
|
"CONTRIBUTING.md",
|
|
65
67
|
"LICENSE",
|
|
@@ -74,10 +76,11 @@
|
|
|
74
76
|
"clean": "node scripts/clean.mjs",
|
|
75
77
|
"build": "node scripts/build.mjs",
|
|
76
78
|
"lint": "eslint . && node scripts/check-markdown.mjs",
|
|
77
|
-
"test": "npm run build && npm run lint && npm run test:coverage && npm run test:types && npm run test:package && npm run test:install && npm run test:docs",
|
|
79
|
+
"test": "npm run build && npm run lint && npm run test:coverage && npm run test:types && npm run test:examples && npm run test:package && npm run test:install && npm run test:docs",
|
|
78
80
|
"test:unit": "node --test --test-reporter=spec test/*.test.mjs",
|
|
79
81
|
"test:coverage": "c8 --all --src src --check-coverage --lines 100 --functions 100 --statements 100 --branches 95 node --test test/adapters.test.mjs test/router.test.mjs test/search-tool.test.mjs test/security.test.mjs test/tokenize.test.mjs",
|
|
80
82
|
"test:types": "node scripts/test-types.mjs",
|
|
83
|
+
"test:examples": "node examples/openai-responses.mjs && node examples/openai-chat.mjs && node examples/anthropic.mjs && node examples/gemini.mjs && node examples/mcp.mjs && node examples/dynamic-catalog.mjs && node examples/large-catalog.mjs",
|
|
81
84
|
"test:package": "node --test test/package.test.mjs && node scripts/check-dist.mjs && publint",
|
|
82
85
|
"test:install": "node scripts/smoke-install.mjs",
|
|
83
86
|
"test:docs": "npm run docs:build && node scripts/check-docs.mjs",
|