@spendgraph/tools 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +3 -2
  2. package/dist/builtin/calculate/calculate.d.ts +2 -0
  3. package/dist/builtin/calculate/calculate.js +1 -1
  4. package/dist/builtin/calculate/index.d.ts +2 -1
  5. package/dist/builtin/calculate/index.js +1 -1
  6. package/dist/builtin/code-exec/code-exec.d.ts +14 -0
  7. package/dist/builtin/date/date.d.ts +1 -1
  8. package/dist/builtin/date/date.js +1 -1
  9. package/dist/builtin/date/index.d.ts +1 -0
  10. package/dist/builtin/date/index.js +1 -1
  11. package/dist/builtin/date/shift.d.ts +1 -1
  12. package/dist/builtin/date/shift.js +1 -1
  13. package/dist/builtin/date/words.d.ts +13 -0
  14. package/dist/builtin/date/words.js +1 -0
  15. package/dist/builtin/decline/decline.d.ts +49 -0
  16. package/dist/builtin/decline/decline.js +1 -0
  17. package/dist/builtin/decline/index.d.ts +1 -0
  18. package/dist/builtin/decline/index.js +1 -0
  19. package/dist/builtin/escalate/escalate.d.ts +19 -0
  20. package/dist/builtin/escalate/escalate.js +1 -1
  21. package/dist/builtin/escalate/index.d.ts +1 -1
  22. package/dist/builtin/escalate/index.js +1 -1
  23. package/dist/builtin/extract/extract.d.ts +25 -0
  24. package/dist/builtin/extract/extract.js +1 -0
  25. package/dist/builtin/extract/index.d.ts +3 -0
  26. package/dist/builtin/extract/index.js +1 -0
  27. package/dist/builtin/extract/kinds.d.ts +6 -0
  28. package/dist/builtin/extract/kinds.js +1 -0
  29. package/dist/builtin/extract/readers.d.ts +7 -0
  30. package/dist/builtin/extract/readers.js +9 -0
  31. package/dist/builtin/finish/finish.d.ts +4 -0
  32. package/dist/builtin/finish/finish.js +1 -1
  33. package/dist/builtin/http/allow.d.ts +2 -0
  34. package/dist/builtin/http/allow.js +1 -1
  35. package/dist/builtin/http/hops.d.ts +10 -0
  36. package/dist/builtin/http/hops.js +1 -0
  37. package/dist/builtin/http/http.d.ts +9 -1
  38. package/dist/builtin/http/http.js +1 -1
  39. package/dist/builtin/http/index.d.ts +2 -0
  40. package/dist/builtin/http/index.js +1 -1
  41. package/dist/builtin/http/retry-after.d.ts +14 -0
  42. package/dist/builtin/http/retry-after.js +1 -0
  43. package/dist/builtin/index.d.ts +11 -6
  44. package/dist/builtin/index.js +1 -1
  45. package/dist/builtin/json/index.d.ts +1 -0
  46. package/dist/builtin/json/index.js +1 -1
  47. package/dist/builtin/json/json.js +1 -1
  48. package/dist/builtin/json/parse.d.ts +15 -0
  49. package/dist/builtin/json/parse.js +1 -0
  50. package/dist/builtin/notes/index.d.ts +1 -0
  51. package/dist/builtin/notes/index.js +1 -0
  52. package/dist/builtin/notes/notes.d.ts +45 -0
  53. package/dist/builtin/notes/notes.js +2 -0
  54. package/dist/builtin/openalex/api.d.ts +26 -0
  55. package/dist/builtin/openalex/api.js +1 -0
  56. package/dist/builtin/openalex/index.d.ts +3 -0
  57. package/dist/builtin/openalex/index.js +1 -0
  58. package/dist/builtin/openalex/openalex.d.ts +36 -0
  59. package/dist/builtin/openalex/openalex.js +1 -0
  60. package/dist/builtin/paper-search/index.d.ts +1 -0
  61. package/dist/builtin/paper-search/index.js +1 -0
  62. package/dist/builtin/paper-search/paper-search.d.ts +38 -0
  63. package/dist/builtin/paper-search/paper-search.js +3 -0
  64. package/dist/builtin/polite.d.ts +15 -0
  65. package/dist/builtin/polite.js +1 -0
  66. package/dist/builtin/publish/index.d.ts +1 -0
  67. package/dist/builtin/publish/index.js +1 -0
  68. package/dist/builtin/publish/publish.d.ts +46 -0
  69. package/dist/builtin/publish/publish.js +1 -0
  70. package/dist/builtin/web-search/web-search.d.ts +10 -35
  71. package/dist/builtin/web-search/web-search.js +1 -1
  72. package/dist/builtin/wikidata/api.d.ts +40 -0
  73. package/dist/builtin/wikidata/api.js +1 -0
  74. package/dist/builtin/wikidata/index.d.ts +5 -0
  75. package/dist/builtin/wikidata/index.js +1 -0
  76. package/dist/builtin/wikidata/render.d.ts +13 -0
  77. package/dist/builtin/wikidata/render.js +2 -0
  78. package/dist/builtin/wikidata/wikidata.d.ts +23 -0
  79. package/dist/builtin/wikidata/wikidata.js +1 -0
  80. package/dist/bus/bus.d.ts +23 -1
  81. package/dist/bus/bus.js +1 -1
  82. package/dist/bus/group.d.ts +14 -1
  83. package/dist/bus/group.js +1 -1
  84. package/dist/bus/index.d.ts +4 -1
  85. package/dist/bus/index.js +1 -1
  86. package/dist/bus/invoke.d.ts +1 -1
  87. package/dist/bus/invoke.js +3 -1
  88. package/dist/bus/namespace.d.ts +21 -0
  89. package/dist/bus/namespace.js +1 -0
  90. package/dist/bus/once.d.ts +32 -0
  91. package/dist/bus/once.js +1 -0
  92. package/dist/bus/only.d.ts +29 -0
  93. package/dist/bus/options.d.ts +31 -0
  94. package/dist/bus/output.d.ts +0 -17
  95. package/dist/bus/output.js +2 -2
  96. package/dist/bus/select.d.ts +0 -16
  97. package/dist/bus/select.js +1 -1
  98. package/dist/bus/spent.d.ts +16 -0
  99. package/dist/bus/spent.js +1 -0
  100. package/dist/bus/trace.d.ts +14 -1
  101. package/dist/bus/trace.js +1 -1
  102. package/dist/index.d.ts +6 -4
  103. package/dist/index.js +1 -1
  104. package/dist/internals.d.ts +8 -1
  105. package/dist/internals.js +1 -1
  106. package/dist/memories/client.d.ts +91 -0
  107. package/dist/memories/client.js +0 -0
  108. package/dist/memories/context.d.ts +5 -0
  109. package/dist/memories/context.js +1 -0
  110. package/dist/{builtin → memories}/deep-recall/deep-recall.d.ts +3 -0
  111. package/dist/memories/deep-recall/deep-recall.js +1 -0
  112. package/dist/memories/explore/explore.d.ts +18 -0
  113. package/dist/memories/explore/explore.js +7 -0
  114. package/dist/memories/explore/subgraph.d.ts +12 -0
  115. package/dist/memories/explore/subgraph.js +3 -0
  116. package/dist/memories/index.d.ts +30 -0
  117. package/dist/memories/index.js +1 -0
  118. package/dist/memories/observe/observe.d.ts +7 -0
  119. package/dist/memories/observe/observe.js +1 -0
  120. package/dist/memories/recall/recall.d.ts +42 -0
  121. package/dist/memories/recall/recall.js +1 -0
  122. package/dist/memories/recall/render.d.ts +5 -0
  123. package/dist/memories/recall/render.js +3 -0
  124. package/dist/memories/remember/remember.d.ts +49 -0
  125. package/dist/memories/remember/remember.js +1 -0
  126. package/dist/papers/consensus/consensus.d.ts +12 -0
  127. package/dist/papers/consensus/consensus.js +1 -0
  128. package/dist/papers/consensus/index.d.ts +4 -0
  129. package/dist/papers/consensus/index.js +1 -0
  130. package/dist/papers/consensus/reply.d.ts +24 -0
  131. package/dist/papers/consensus/reply.js +1 -0
  132. package/dist/papers/every.d.ts +6 -0
  133. package/dist/papers/every.js +1 -0
  134. package/dist/papers/index.d.ts +11 -0
  135. package/dist/papers/index.js +1 -0
  136. package/dist/papers/openalex/index.d.ts +2 -0
  137. package/dist/papers/openalex/index.js +1 -0
  138. package/dist/papers/openalex/openalex.d.ts +8 -0
  139. package/dist/papers/openalex/openalex.js +1 -0
  140. package/dist/papers/papers.d.ts +54 -0
  141. package/dist/papers/papers.js +1 -0
  142. package/dist/papers/render.d.ts +2 -0
  143. package/dist/papers/render.js +4 -0
  144. package/dist/papers/semantic-scholar/index.d.ts +4 -0
  145. package/dist/papers/semantic-scholar/index.js +1 -0
  146. package/dist/papers/semantic-scholar/reply.d.ts +29 -0
  147. package/dist/papers/semantic-scholar/reply.js +1 -0
  148. package/dist/papers/semantic-scholar/semantic-scholar.d.ts +8 -0
  149. package/dist/papers/semantic-scholar/semantic-scholar.js +1 -0
  150. package/dist/search/every.d.ts +6 -0
  151. package/dist/search/every.js +4 -0
  152. package/dist/search/index.d.ts +7 -0
  153. package/dist/search/index.js +1 -0
  154. package/dist/search/retry.d.ts +6 -0
  155. package/dist/search/retry.js +1 -0
  156. package/dist/search/search.d.ts +69 -0
  157. package/dist/search/search.js +1 -0
  158. package/dist/search/searxng/index.d.ts +2 -0
  159. package/dist/search/searxng/index.js +1 -0
  160. package/dist/search/searxng/searxng.d.ts +14 -0
  161. package/dist/search/searxng/searxng.js +5 -0
  162. package/dist/search/searxng/text.d.ts +2 -0
  163. package/dist/search/searxng/text.js +3 -0
  164. package/dist/search/sonar/index.d.ts +2 -0
  165. package/dist/search/sonar/index.js +1 -0
  166. package/dist/search/sonar/sonar.d.ts +13 -0
  167. package/dist/search/sonar/sonar.js +1 -0
  168. package/dist/search/tavily/index.d.ts +4 -0
  169. package/dist/search/tavily/index.js +1 -0
  170. package/dist/search/tavily/reply.d.ts +22 -0
  171. package/dist/search/tavily/reply.js +5 -0
  172. package/dist/search/tavily/tavily.d.ts +14 -0
  173. package/dist/search/tavily/tavily.js +1 -0
  174. package/dist/testing/index.d.ts +2 -0
  175. package/dist/testing/index.js +1 -0
  176. package/dist/testing/replay.d.ts +55 -0
  177. package/dist/testing/replay.js +1 -0
  178. package/dist/testing/store.d.ts +35 -0
  179. package/dist/testing/store.js +4 -0
  180. package/dist/tool/tool.d.ts +8 -1
  181. package/dist/tool/tool.js +1 -1
  182. package/dist/types/index.d.ts +1 -1
  183. package/dist/types/result.d.ts +38 -0
  184. package/dist/types/tool.d.ts +74 -0
  185. package/docs/bogus.mdx +85 -0
  186. package/docs/builtins.mdx +217 -0
  187. package/docs/bus.mdx +90 -0
  188. package/docs/declaring.mdx +85 -0
  189. package/docs/effects.mdx +49 -0
  190. package/docs/overview.mdx +76 -0
  191. package/docs/selecting.mdx +79 -0
  192. package/docs/stored.mdx +83 -0
  193. package/docs/turns.mdx +57 -0
  194. package/package.json +28 -9
  195. package/dist/builtin/deep-recall/deep-recall.js +0 -1
  196. package/dist/builtin/write-document/commission.d.ts +0 -56
  197. package/dist/builtin/write-document/commission.js +0 -13
  198. package/dist/builtin/write-document/design.d.ts +0 -49
  199. package/dist/builtin/write-document/design.js +0 -5
  200. package/dist/builtin/write-document/http.d.ts +0 -34
  201. package/dist/builtin/write-document/http.js +0 -1
  202. package/dist/builtin/write-document/index.d.ts +0 -6
  203. package/dist/builtin/write-document/index.js +0 -1
  204. package/dist/builtin/write-document/types.d.ts +0 -33
  205. package/dist/builtin/write-document/write-document.d.ts +0 -69
  206. package/dist/builtin/write-document/write-document.js +0 -1
  207. /package/dist/{builtin/write-document/types.js → bus/only.js} +0 -0
  208. /package/dist/{builtin → memories}/deep-recall/index.d.ts +0 -0
  209. /package/dist/{builtin → memories}/deep-recall/index.js +0 -0
  210. /package/dist/{builtin/web-search → search/sonar}/reply.d.ts +0 -0
  211. /package/dist/{builtin/web-search → search/sonar}/reply.js +0 -0
@@ -0,0 +1,4 @@
1
+ const O={policy:.95,verified:.9,tool:.8,document:.75,user:.7,assistant:.6,derived:.55,system:.5},g=c=>new Set(c.toLowerCase().split(/[^a-z0-9]+/).filter(o=>o.length>2));function w(c,o){const r=g(c),i=g(`${o.text} ${o.context.replace(":"," ")}`);let d=0;for(const p of r)i.has(p)&&(d+=1);return r.size===0?0:d/r.size}const z=c=>O[c]??.5,$=(c,o)=>c.map(r=>({memory:r,score:w(o,r)*.75+z(r.source)*.25})).filter(r=>w(o,r.memory)>0).sort((r,i)=>i.score-r.score),k=c=>[...c.extends,...c.related_to,...c.reinforces,...c.contradicts],h=(c,o,r,i=new Set)=>o===r?!0:i.has(o)?!1:(i.add(o),c.filter(d=>d.context===o).flatMap(k).some(d=>h(c,d,r,i))),v=(c,o,r)=>!r?.length||r.some(i=>h(c,o.context,i)||k(o).some(d=>h(c,d,i)));function T(c=[]){const o=[],r=[];let i=1;const d=t=>{const s={id:`m${i++}`,context:t.context,kind:t.kind??"fact",source:t.source??"assistant",text:t.text,details:t.details??{},extends:t.extends??[],related_to:t.related_to??[],reinforces:t.reinforces??[],contradicts:t.contradicts??[]};return o.push(s),s},p=()=>{const t=new Map;for(const s of o)t.set(s.context,(t.get(s.context)??0)+1);return[...t].map(([s,e])=>{const n=s.indexOf(":");return{context_id:s,context_type:s.slice(0,n),context_name:s.slice(n+1),reference_count:e}})},S=t=>{const s=[];for(const e of o)e.context===t?(s.push(...e.extends.map(n=>`extends \u2192 ${n}`)),s.push(...e.related_to.map(n=>`related_to \u2192 ${n}`)),s.push(...e.reinforces.map(n=>`reinforces \u2192 ${n}`)),s.push(...e.contradicts.map(n=>`contradicts \u2192 ${n}`))):(e.extends.includes(t)&&s.push(`extended_by \u2190 ${e.context}`),e.related_to.includes(t)&&s.push(`related_to \u2190 ${e.context}`),e.reinforces.includes(t)&&s.push(`reinforced_by \u2190 ${e.context}`),e.contradicts.includes(t)&&s.push(`contradicted_by \u2190 ${e.context}`));return[...new Set(s)]},b=t=>t.map((s,e)=>` ${e===t.length-1?"\u2514\u2500":"\u251C\u2500"} ${s}`),y=t=>`${t.map(s=>[s.context_id,...b(S(s.context_id))].join(`
2
+ `)).join(`
3
+ `)}
4
+ `;for(const t of c)d(t);return{memories:o,observed:r,remember:d,async retrieveMemories(t){const e=$(o.filter(n=>v(o,n,t.contextIds)),t.query).filter(({memory:n})=>!t.sources?.length||t.sources.includes(n.source)).slice(0,t.limit??10).map(({memory:n,score:l})=>({locus_id:n.id,context_ids:[n.context,...n.extends,...n.related_to,...n.reinforces].sort(),text:n.text,score:l,source:n.source,kind:n.kind}));return{items_found:e.length,items:e}},async deepRecall(t){const e=$(o.filter(n=>v(o,n,t.contextIds)),t.question).map(({memory:n,score:l})=>({text:n.text,source:n.source,locus:n.id,contexts:[n.context],score:l}));return{facts:e,contexts_walked:[...new Set(e.flatMap(n=>n.contexts))],...e[0]?{summary:e[0].text}:{},coverage:e.length?"full":"partial",fallback:!1,usage:{cost_usd:0}}},async storeEventsBatch(t){const s=t.map(e=>{const{data:n,...l}=e.payload;return d({context:e.context_id??"topic:untitled",text:typeof n=="string"?n:JSON.stringify(e.payload),details:l,kind:e.event_kind,source:e.source,extends:e.extends,related_to:e.related_to,reinforces:e.reinforces,contradicts:e.contradicts}),{status:"recorded",event_id:`m${i-1}`,relevance:"high"}});return{stored:s.length,filtered:0,failed:0,results:s}},async searchContexts(t,s,e){const n=t.toLowerCase(),l=p().filter(x=>!e?.context_type||x.context_type===e.context_type).filter(x=>x.context_id.toLowerCase().includes(n)).slice(0,e?.page_size??20);return{contexts:l,total:l.length,...e?.include==="relationships"?{relationships_text:y(l)}:{}}},async listContextTypes(){const t=new Map;for(const e of p())t.set(e.context_type,(t.get(e.context_type)??0)+e.reference_count);const s=[...t].map(([e,n])=>({context_type:e,count:n}));return{context_types:s,total:s.length}},async getContextGraph(t){const s=`${t.contextType}:${t.contextName}`,e=t.depth??1,n=p(),l=o.flatMap(a=>[...a.extends.map(f=>({from:a.context,to:f,link_type:"extends"})),...a.related_to.map(f=>({from:a.context,to:f,link_type:"related_to"})),...a.reinforces.map(f=>({from:a.context,to:f,link_type:"reinforces"})),...a.contradicts.map(f=>({from:a.context,to:f,link_type:"contradicts"}))]),x=new Set([s]);let _=[s];for(let a=0;a<e&&_.length;a+=1){const f=l.filter(u=>_.includes(u.from)||_.includes(u.to)).flatMap(u=>[u.from,u.to]).filter(u=>!x.has(u));for(const u of f)x.add(u);_=[...new Set(f)]}const M=n.filter(a=>x.has(a.context_id)),C=[...new Set(l.filter(a=>x.has(a.from)&&x.has(a.to)))];return{nodes:M,edges:C}},async observe(t){return r.push({source:t.source,payload:t.payload,...t.stream?{stream:t.stream}:{}}),{observed:!0}}}}export{T as memoryStore};
@@ -1,4 +1,4 @@
1
- import type { ArgSpec, ArgsOf, Effect, Tool } from "../types/index.js";
1
+ import type { ArgSpec, ArgsOf, Effect, Tool, Trust } from "../types/index.js";
2
2
  export interface ToolSpec<T extends readonly ArgSpec[]> {
3
3
  name: string;
4
4
  description: string;
@@ -6,6 +6,13 @@ export interface ToolSpec<T extends readonly ArgSpec[]> {
6
6
  aliases?: readonly string[];
7
7
  pinned?: boolean;
8
8
  effect?: Effect;
9
+ trust?: Trust;
10
+ costOf?(value: unknown): number | undefined;
11
+ needs?: readonly string[];
12
+ wants?: {
13
+ name: string;
14
+ description: string;
15
+ };
9
16
  run(args: ArgsOf<T>): Promise<unknown> | unknown;
10
17
  }
11
18
  /**
package/dist/tool/tool.js CHANGED
@@ -1 +1 @@
1
- const r=/^[A-Za-z_][A-Za-z0-9_]*$/,i=["readonly","idempotent","destructive"];function a(n,e){if(!e.options?.length)throw new Error(`Tool "${n}" argument "${e.name}" is an enum with no options, so nothing can satisfy it.`);const t=new Set;for(const o of e.options){if(!o.trim())throw new Error(`Tool "${n}" argument "${e.name}" has a blank option, which the model can pick and you cannot act on.`);if(t.has(o))throw new Error(`Tool "${n}" argument "${e.name}" lists "${o}" twice.`);t.add(o)}}function s(n,e){if(e){for(const t of e)if(!t.trim())throw new Error(`Tool "${n}" has a blank alias, which matches every query or none.`)}}function f(n,e){const t=new Set;for(const o of e){if(!r.test(o.name))throw new Error(`Tool "${n}" argument "${o.name}" is not a usable name.`);if(t.has(o.name))throw new Error(`Tool "${n}" declares "${o.name}" twice.`);t.add(o.name),o.type==="enum"&&a(n,o)}}function h(n){if(!r.test(n.name))throw new Error(`Tool name "${n.name}" must be letters, digits and underscores, starting with a letter.`);if(!n.description.trim())throw new Error(`Tool "${n.name}" has no description. It is the only thing the model reads to decide whether to call it.`);if(f(n.name,n.args),s(n.name,n.aliases),n.effect!==void 0&&!i.includes(n.effect))throw new Error(`Tool "${n.name}" declares effect "${n.effect}", which is not one of ${i.join(", ")}.`);return{name:n.name,description:n.description,args:n.args.map(e=>({...e,options:e.options?[...e.options]:void 0})),...n.aliases?.length?{aliases:[...n.aliases]}:{},pinned:n.pinned,...n.effect?{effect:n.effect}:{},run:n.run}}export{h as tool};
1
+ const r=/^[A-Za-z_][A-Za-z0-9_]*$/,i=["readonly","idempotent","destructive"],a=["own","external"];function s(n,t){if(!t.options?.length)throw new Error(`Tool "${n}" argument "${t.name}" is an enum with no options, so nothing can satisfy it.`);const e=new Set;for(const o of t.options){if(!o.trim())throw new Error(`Tool "${n}" argument "${t.name}" has a blank option, which the model can pick and you cannot act on.`);if(e.has(o))throw new Error(`Tool "${n}" argument "${t.name}" lists "${o}" twice.`);e.add(o)}}function f(n,t){if(t){for(const e of t)if(!e.trim())throw new Error(`Tool "${n}" has a blank alias, which matches every query or none.`)}}function h(n,t){const e=new Set;for(const o of t){if(!r.test(o.name))throw new Error(`Tool "${n}" argument "${o.name}" is not a usable name.`);if(e.has(o.name))throw new Error(`Tool "${n}" declares "${o.name}" twice.`);e.add(o.name),o.type==="enum"&&s(n,o)}}function d(n){if(!r.test(n.name))throw new Error(`Tool name "${n.name}" must be letters, digits and underscores, starting with a letter.`);if(!n.description.trim())throw new Error(`Tool "${n.name}" has no description. It is the only thing the model reads to decide whether to call it.`);if(h(n.name,n.args),f(n.name,n.aliases),n.effect!==void 0&&!i.includes(n.effect))throw new Error(`Tool "${n.name}" declares effect "${n.effect}", which is not one of ${i.join(", ")}.`);if(n.trust!==void 0&&!a.includes(n.trust))throw new Error(`Tool "${n.name}" declares trust "${n.trust}", which is not one of ${a.join(", ")}.`);return{name:n.name,description:n.description,args:n.args.map(t=>({...t,options:t.options?[...t.options]:void 0})),...n.aliases?.length?{aliases:[...n.aliases]}:{},pinned:n.pinned,...n.effect?{effect:n.effect}:{},...n.trust?{trust:n.trust}:{},...n.costOf?{costOf:n.costOf}:{},...n.needs?.length?{needs:[...n.needs]}:{},...n.wants?{wants:{...n.wants}}:{},run:n.run}}export{d as tool};
@@ -1,2 +1,2 @@
1
1
  export type { ToolResult } from "./result.js";
2
- export type { ArgSpec, ArgsOf, ArgValue, Effect, Tool, ToolDeclaration } from "./tool.js";
2
+ export type { ArgSpec, ArgsOf, ArgValue, Effect, Tool, ToolDeclaration, Trust } from "./tool.js";
@@ -1,3 +1,5 @@
1
+ import type { Source } from "../search/search.js";
2
+ import type { Trust } from "./tool.js";
1
3
  /** What happened when one was called. */
2
4
  export interface ToolResult {
3
5
  name: string;
@@ -7,4 +9,40 @@ export interface ToolResult {
7
9
  output: string;
8
10
  error?: string;
9
11
  latencyMs: number;
12
+ /**
13
+ * What the tool declared about where its content came from.
14
+ *
15
+ * Carried onto the result so a hook watching what has already run can tell
16
+ * that untrusted content entered the context, without matching on a name.
17
+ */
18
+ trust?: Trust;
19
+ /** What this call itself cost, when the tool declared how to read it. */
20
+ costMicros?: number;
21
+ /**
22
+ * Where the content came from, when the tool returned it apart from the prose.
23
+ *
24
+ * A search tool already knows each result's url; rendering the output flattens
25
+ * that into text nobody downstream can read back. Carried here so a citation
26
+ * survives as a link rather than as something to parse out of a paragraph.
27
+ */
28
+ sources?: Source[];
29
+ /**
30
+ * Set when the output was cut to fit, with what it was cut from.
31
+ *
32
+ * Present only when something was actually dropped, so its absence is the
33
+ * claim that the output is whole.
34
+ */
35
+ truncated?: {
36
+ kept: number;
37
+ of: number;
38
+ };
39
+ /**
40
+ * Set when this exact destructive call had already run and the earlier result
41
+ * was handed back instead of running it again.
42
+ *
43
+ * The output is the first call's, unchanged, because it is still true — the
44
+ * refund did happen. This is for whoever counts how often the agent asked
45
+ * twice, which is a fact about the agent and not about the answer.
46
+ */
47
+ repeated?: true;
10
48
  }
@@ -66,6 +66,22 @@ export type ArgsOf<T extends readonly ArgSpec[]> = {
66
66
  * the same as safe.
67
67
  */
68
68
  export type Effect = "readonly" | "idempotent" | "destructive";
69
+ /**
70
+ * Where what a tool returns came from.
71
+ *
72
+ * `own` is yours: a database you run, a file you wrote, a calculation. `external`
73
+ * is content from outside that somebody else can write — a fetched page, a
74
+ * search result, an inbox, a document a customer uploaded. What comes back from
75
+ * one of those is data, and anything in it that reads like an instruction was
76
+ * put there by whoever controls the source.
77
+ *
78
+ * Declared rather than inferred, and separate from `effect`, because the two are
79
+ * independent: reading a web page is `readonly` and `external`, reading your own
80
+ * ledger is `readonly` and `own`, and only the first can carry an instruction
81
+ * written by a stranger. Unset means nobody has said, which is not the same as
82
+ * safe.
83
+ */
84
+ export type Trust = "own" | "external";
69
85
  /**
70
86
  * A description the model reads and a function you wrote, in one object.
71
87
  *
@@ -90,6 +106,19 @@ export interface Tool<Args = Record<string, unknown>> {
90
106
  * where the words a caller actually types are declared.
91
107
  */
92
108
  aliases?: string[];
109
+ /**
110
+ * A second, differently-shaped input the tool would rather be given, named.
111
+ *
112
+ * A stage hands every tool the one question it wrote. That suits a search
113
+ * engine and defeats a catalogue that matches on a title: measured against
114
+ * OpenAlex, a 280-character sub-question found nothing where the same ask as
115
+ * an 80-character phrase found ten papers. Declaring it here is what lets the
116
+ * stage be told to write one, without the stored wording naming any tool.
117
+ */
118
+ wants?: {
119
+ name: string;
120
+ description: string;
121
+ };
93
122
  /**
94
123
  * Offered whatever selection decides — an escalation path, a refusal, where
95
124
  * losing a similarity search is not an acceptable outcome.
@@ -97,6 +126,51 @@ export interface Tool<Args = Record<string, unknown>> {
97
126
  pinned?: boolean;
98
127
  /** What calling it does outside the process. Unset means unknown. */
99
128
  effect?: Effect;
129
+ /**
130
+ * Whether what it returns is content somebody outside wrote. Unset means unknown.
131
+ *
132
+ * A guard that wants to say "no destructive call after untrusted content came
133
+ * in" has to gate on something the tool declared. Without this the only thing
134
+ * left to gate on is the tool's name, which the hook documentation warns
135
+ * against for the reason it always does: a rename turns the rule off, silently
136
+ * and in the permissive direction.
137
+ */
138
+ trust?: Trust;
139
+ /**
140
+ * Reads what one call cost, in micro-USD, out of what `run` returned.
141
+ *
142
+ * Some tools spend money: a deep recall runs model calls of its own, a judge
143
+ * tool pays for a verdict, a sub-agent tool pays for a whole run. That spend
144
+ * is invisible to the loop that called them — token counts belong to the
145
+ * outer model call and a tool's own bill is not in them. Without this the
146
+ * number exists only inside the stringified result, where the model reads it
147
+ * and no code can add it up.
148
+ *
149
+ * A reader rather than a field on the result, because `run` returns whatever
150
+ * suits the tool and only the tool knows which part of it is the price.
151
+ */
152
+ costOf?(value: unknown): number | undefined;
153
+ /**
154
+ * Tools that have to be offered alongside this one, or not at all.
155
+ *
156
+ * Selection scores each tool on its own, which is the wrong unit when two of
157
+ * them are a cheap entry point and its expensive counterpart. `deep_recall`
158
+ * and `recall` overlap on almost every term, so a shortlist of three routinely
159
+ * kept the dear one and dropped the cheap one — while both descriptions tell
160
+ * the model to try the cheap one first. The model was then instructed to
161
+ * prefer a tool it had not been given.
162
+ *
163
+ * Enforced as a guarantee rather than a preference: where the shortlist has
164
+ * no room for the pair, the tool that declared the need is dropped instead.
165
+ * Offering the expensive half alone is the failure this exists to stop, and a
166
+ * best-effort version would still do it whenever the ceiling was tight.
167
+ *
168
+ * A name no tool on the bus carries is ignored rather than refused. The
169
+ * constraint is about what a shortlist offers, and a partner that is not on
170
+ * the bus cannot be dropped in favour of anything — a bus holding this tool
171
+ * alone has no selection problem to have.
172
+ */
173
+ needs?: readonly string[];
100
174
  run(args: Args): Promise<unknown> | unknown;
101
175
  }
102
176
  /** A tool as the model sees it: no implementation, no pin. */
package/docs/bogus.mdx ADDED
@@ -0,0 +1,85 @@
1
+ export const meta = {
2
+ title: "Bogus tools — spendgraph docs",
3
+ description:
4
+ "Twelve tools that answer the same way every time, built so the answer cannot be known without calling. Nothing reaches the network, so the noise in a run is the model's own.",
5
+ };
6
+
7
+ # Bogus tools
8
+
9
+ Twelve tools that answer the same way every time.
10
+
11
+ ```sh
12
+ npm install @spendgraph/tools
13
+ ```
14
+
15
+ ```ts
16
+ import { bogusTools } from "@spendgraph/tools/bogus";
17
+
18
+ const bus = toolbus(bogusTools());
19
+ ```
20
+
21
+ Its own subpath, so nothing here reaches a production bundle by accident.
22
+
23
+ ## For testing a pipeline, not a provider
24
+
25
+ Nothing here touches the network. No key, no rate limit, no flake, no bill — so whatever noise a run shows is the **model's own**, not the weather between you and a provider. Run the same question a hundred times and the tools are the one thing that did not move.
26
+
27
+ ## Every answer is unknowable without the call
28
+
29
+ This is the part that makes them worth more than stubs. The Bogus League's clubs exist nowhere else. The dictionary's definitions are its own. The gold rate is not the market rate, and the prayer times match no published table. Each description says so.
30
+
31
+ A model that answers from memory is therefore **caught**, not merely unhelpful — there is no training data to be accidentally right from.
32
+
33
+ ## The receipt
34
+
35
+ Every reply carries a token the model could not have produced:
36
+
37
+ ```ts
38
+ { found: true, note: "…", written: "2026-08-01", receipt: "bogus-my_notes-4f2a1c" }
39
+ ```
40
+
41
+ ```ts
42
+ import { receipt } from "@spendgraph/tools/bogus";
43
+
44
+ receipt("my_notes", "wifi"); // "bogus-my_notes-9e01b3"
45
+ ```
46
+
47
+ Grep an answer for `bogus-` and you know which tools actually reached it. The model cannot invent a receipt it never saw, so provenance is **observed rather than claimed** — which is a different and stronger thing than reading the step log, since the log records what your code ran and the receipt records what the answer was actually built from.
48
+
49
+ ## The twelve
50
+
51
+ | | | |
52
+ | --- | --- | --- |
53
+ | `my_notes` | the user's own notes — passwords, plans, things written down | `readonly` |
54
+ | `my_calendar` | what is on for a given day | `readonly` |
55
+ | `my_recipes` | only what they saved themselves, not a cookbook | `readonly` |
56
+ | `dictionary` | definitions that are its own and not the ones in general use | `readonly` |
57
+ | `football_score` | the Bogus League, whose clubs exist nowhere else | `readonly` |
58
+ | `gold_price` | the Bogus Exchange rate per bhori, which is not the market | `readonly` |
59
+ | `prayer_times` | the Bogus Almanac, matching no published table | `readonly` |
60
+ | `calculator` | exact arithmetic on numbers already in hand | `readonly` |
61
+ | `feed_cat` | one portion to the feeder; safe twice, the cat will say otherwise | `idempotent` |
62
+ | `order_pizza` | charges the card, no undo, a pizza arrives regardless | `destructive` |
63
+ | `book_taxi` | dispatches a driver who charges for waiting | `destructive` |
64
+ | `ask_mum` | always answers, rarely the question asked, forty-one minutes | `destructive` |
65
+
66
+ <Callout tone="trap" title="They are fixtures, not fakes of anything real">
67
+ Every answer is unknowable without the call — that is what makes them useful for testing a loop and useless for testing a provider. Ship one by accident and the model will happily tell a customer what the gold price is.
68
+ </Callout>
69
+
70
+ ## All three effects, on purpose
71
+
72
+ Eight `readonly`, one `idempotent`, three `destructive`. That spread is why this is the fixture for a gate: you can assert a policy refuses `order_pizza`, retries `feed_cat`, and waves `my_notes` through — without a bus that can spend real money.
73
+
74
+ ```ts
75
+ const bus = toolbus(bogusTools());
76
+
77
+ bus.destructive(); // ["order_pizza", "book_taxi", "ask_mum"]
78
+ bus.unannotated(); // []
79
+ ```
80
+
81
+ `unannotated()` is empty by construction. A bogus tool added without an effect would show up there, which is the assertion worth keeping in a test.
82
+
83
+ ## What they are not
84
+
85
+ They are not a benchmark. A model doing well on the Bogus League says it can follow a description and call a tool, not that it is good — the questions were written to be answerable only one way. Use them to prove the wiring, then measure quality on real work.
@@ -0,0 +1,217 @@
1
+ export const meta = {
2
+ title: "Builtins — spendgraph docs",
3
+ description:
4
+ "Twenty-one factories, none registered for you — this package's own advice is that more tools makes a model worse at picking, so they are opt-in one at a time.",
5
+ };
6
+
7
+ # Builtins
8
+
9
+ Twenty-one, each a factory, **none registered for you** — this package's own advice is that more tools in front of a model makes it worse at picking, so they are opt-in one at a time. Six need a key, one needs a sandbox; the rest are local code.
10
+
11
+ ```ts
12
+ import { calculate, currentTime, escalate, finish, think, httpRequest } from "@spendgraph/tools";
13
+
14
+ const bus = toolbus([
15
+ calculate(),
16
+ think(),
17
+ finish(),
18
+ currentTime({ defaultTimeZone: "Europe/London" }),
19
+ escalate({ onEscalate: (r) => pager.page(r) }),
20
+ httpRequest({ allow: [".example.com"], headers: { authorization: `Bearer ${key}` } }),
21
+ ]);
22
+ ```
23
+
24
+ | | | |
25
+ | --- | --- | --- |
26
+ | `calculate()` | arithmetic, **parsed not evaluated** | local |
27
+ | `think()` | a scratchpad that does nothing | local |
28
+ | `notes()` | a board the model posts to and reads back, kept for the session | local |
29
+ | `finish()` | the model says it is done, and what the answer is; `pinned` by default | local |
30
+ | `currentTime()` | the clock a model does not have | local |
31
+ | `escalate()` | hand back to a person; `pinned` by default | local |
32
+ | `decline()` | the model says it cannot answer, and which kind of cannot | local |
33
+ | `dateMath()` | add, subtract and difference, in whole units | local |
34
+ | `dates()` | the same, asked in words, for a caller that can only send a question | local |
35
+ | `jsonQuery()` | one value out of a JSON document, by path | local |
36
+ | `httpRequest()` | a GET or POST to hosts you named | local |
37
+ | `extract()` | the text of a file at a URL — pdf, docx, a page, md, txt | local |
38
+ | `codeExec()` | a short program, run wherever you say | sandbox |
39
+ | `publish()` | content out as a file at a public URL, through the app | key |
40
+ | `webSearch()` | a searched answer with its sources, from a searcher you pick | local or key |
41
+ | `wikidata()` | what Wikidata records about a thing, as text with every id named | local |
42
+ | `paperSearch()` | published papers on a topic, as text with a DOI and a PDF where one is open, from a paper searcher you pick | local or key |
43
+ | `recall()` | what your memory holds about something, one note per row | key |
44
+ | `deepRecall()` | the same, walked across linked notes, with the evidence | key |
45
+ | `remember()` | one thing filed under a `type:name` context | key |
46
+ | `exploreMemory()` | the filing scheme, and how contexts link | key |
47
+
48
+ ## Memory
49
+
50
+ The four memory tools are one set over a [LocusGraph](https://locusgraph.com) graph, and `memories()` wires them once:
51
+
52
+ ```ts
53
+ import { memories, toolbus, webSearch } from "@spendgraph/tools";
54
+ import { LocusGraphClient } from "@locusgraph/client";
55
+
56
+ const memory = memories({ client: new LocusGraphClient({ agentSecret }), source: "assistant" });
57
+ const bus = toolbus([...memory.tools, webSearch({ search })]);
58
+
59
+ await memory.observe({ question, answer, sources });
60
+ ```
61
+
62
+ The model sees `recall`, `deep_recall`, `remember` and `explore_memory`, and nothing else. `recall` is the cheap search and says to try itself first, and its rows come back as [TOON](https://github.com/toon-format/toon), a tabular format a model reads like text at fewer tokens than JSON, rendered by this package from the server's JSON so the columns are always `contexts`, `text`, `source` and `relevance`, with `recallFormat: "markdown"` there for a model that reads bullets better; `deep_recall` runs model calls of its own and reports what they cost in `costMicroUsd`. `remember` writes one thing under a context named `type:name`, linked to others with `extends`, `related_to`, `reinforces` or `contradicts`, with optional `details` for the structured fields that belong beside the text, and `explore_memory` with no query lists the types already in use, which is how a model learns the filing scheme before it adds to it.
63
+
64
+ Two things the model never chooses. The **source** every `remember` is stamped with is fixed in the options, `assistant` by default, because a model allowed to stamp its own guess `verified` outranks real facts on the next recall. And **context ids** are normalised on every tool, so `Person: Alice Smith` files under `person:alice_smith` whatever the model typed, and each result echoes the canonical form back.
65
+
66
+ **One graph, many people.** Pass `rootContextId` and the set stays inside one subtree:
67
+
68
+ ```ts
69
+ const alice = memories({ client, rootContextId: "person:alice" });
70
+ ```
71
+
72
+ `recall` filters every search by the root, and the server follows `extends` links, so a note filed under `preference:dark_mode` that extends `person:alice` is found, and so is a note two links down. Contexts the model names become a boost inside that fence rather than a filter of their own, so it cannot search another person's subtree by naming it. `remember` adds the root to every note's `extends`, so what Alice's agent records is hers without the model saying so. `explore_memory` draws the root's subgraph instead of searching the whole graph. `deep_recall` starts its walk at the root; that is a hint to the walker rather than a fence, which is the one place the isolation is advisory.
73
+
74
+ When the root is only known once a run is under way, pass a function instead of a string and it is read on every call:
75
+
76
+ ```ts
77
+ const state: { person?: string } = {};
78
+ const memory = memories({ client, rootContextId: () => state.person });
79
+
80
+ node({ name: "identify", run: async () => { state.person = `person:${await whoIsAsking()}`; } });
81
+ ```
82
+
83
+ A function that returns nothing does not open the whole graph; every memory tool fails with a message saying no root is set, which is the safe side of that mistake. Runs that overlap in one process should keep their state in an `AsyncLocalStorage` rather than a shared object, so each run's tools read their own root.
84
+
85
+ `memory.observe()` is a helper, not a tool. It appends a raw moment, the question, answer and sources of a finished run, and returns at once; a judge on the server decides later what becomes a fact. That fits the end of a run better than asking the model to summarise itself.
86
+
87
+ The package declares the client it needs as a `MemoryClient` interface rather than importing one, so `@locusgraph/client` stays out of your dependencies until you add it, and the real client is proven to satisfy it in this package's tests. `memoryStore()` from `@spendgraph/tools/testing` is an in-process implementation of the same interface, so a loop that uses memory can be tested with no server and no spend:
88
+
89
+ ```ts
90
+ import { memoryStore } from "@spendgraph/tools/testing";
91
+
92
+ const store = memoryStore([{ context: "preference:dark_mode", text: "They prefer dark mode." }]);
93
+ const bus = toolbus(memories({ client: store }).tools);
94
+ ```
95
+
96
+ <Callout tone="trap" title="None of them are registered for you">
97
+ Importing a builtin does nothing until it is in a bus. That is deliberate — a package that silently added `http_request` to every agent would be a package that widened your blast radius on `npm update` — but it does mean a tool you "added" and never see called is usually a tool you never put in the bus.
98
+ </Callout>
99
+
100
+ ## A few worth reading about
101
+
102
+ **`calculate`** parses the expression — numbers, `+ - * / % ^`, brackets, and `sqrt abs round floor ceil ln log` — and refuses everything else. `eval` on model output is arbitrary code execution with extra steps.
103
+
104
+ It answers with the digits a double actually carries, so `0.1 + 0.2` is `0.3` and not `0.30000000000000004`. A whole number past 2^53 is refused rather than silently rounded, because the answer would then be confidently wrong about a figure the model can see in its own request.
105
+
106
+ **`think`** returns `"noted"` and changes nothing. That is the point: the thought lands in the steps instead of in the reply, and a model given room to plan before a multi-step sequence uses the tools better.
107
+
108
+ **`finish`** gives a loop a real stop condition. Without it a run ends when the model goes quiet, which is indistinguishable from it losing the thread. Read the result with `isFinished(result)` and `answerOf(result)`.
109
+
110
+ **`decline`** is the other real ending, and the one usually left to prose. A model that cannot answer will otherwise write an apology, which reads as an answer to everything downstream — it is scored, it is stored, and the rate of it is invisible. The tool takes a fixed `because` — `unknowable`, `not_covered`, `out_of_scope`, `unsafe` — so the rate of each is countable, and the two commonest want opposite fixes: one is the world, the other is your records. `isDeclined(result)` and `declinedFor(result)` read it back, and a loop that ends this way reports `stoppedBy: "declined"` with `declinedBecause` beside it.
111
+
112
+ **`current_time`** is the clock a model does not have. Asked for today's date it will otherwise produce its training cutoff, confidently. A wrong IANA zone throws rather than falling back, because a confidently wrong time is the failure this exists to stop.
113
+
114
+ **`dateMath`** is what `current_time` leads to. Calendar months clamp rather than overflow — a month after 31 January is 28 February, not 3 March, which is what `setMonth` gives and what every billing system disagrees with. `difference` counts boundaries crossed rather than dividing elapsed milliseconds, because a month is not 30 days. `dates()` is the same tool taking one `question` in one of three forms — `days from 2026-03-01 to 2026-09-09`, `30 days after 2026-03-01`, `2 months before 2026-03-01` — for a pipeline that hands every tool a question and nothing else.
115
+
116
+ **`jsonQuery`** reads one value by path — `invoices[0].total`, or `invoices[*].total` for every element. A path that matches nothing comes back `found: false` rather than `null`, which a model would otherwise read as the answer.
117
+
118
+ **`webSearch`** takes a searcher, and ships two. `sonar` is Perplexity, which searches, reads and writes the answer itself, for a key. `searxng` is a [SearXNG](https://docs.searxng.org) you run, which costs nothing and needs no key: it searches, then reads the first hits as text, five on `quick`, eight on `pro`, ten on `deep`. Handed a query that names a link, it reads that page instead.
119
+
120
+ ```ts
121
+ import { webSearch } from "@spendgraph/tools";
122
+ import { searxng, sonar } from "@spendgraph/tools/search";
123
+
124
+ webSearch({ search: sonar({ apiKey, domains: ["gov.uk"] }), maxDepth: "pro" });
125
+ webSearch({ search: searxng({ baseUrl: "http://localhost:8080", language: "en" }), maxDepth: "deep" });
126
+ ```
127
+
128
+ Both hand back the same shape — `answer`, `results`, `model`, `truncated`, `usage` — so whatever reads one reads the other; only where the answer came from and what it cost differ. `everySearch([sonar, tavily, searxng])` is that shape used on itself: one searcher over several, asked at the same time. The model chooses `query`, `depth` and `recency`; `domains`, engines and language are pinned on the searcher, so it cannot widen them. Pages the SearXNG searcher reads go through the same guard as `httpRequest`, so a private address is refused even when an engine offered it. A stock SearXNG answers the json format with a 403 until `settings.yml` lists it under `search.formats` and the limiter is off, and the error says so. `examples/search` runs both searchers directly.
129
+
130
+ **`paperSearch`** is `webSearch` for the literature, and ships three searchers. `openAlexPapers` is [OpenAlex](https://openalex.org), free and keyless. `semanticScholar` is the [Semantic Scholar](https://www.semanticscholar.org/product/api) graph, and carries the `tldr` as each paper's takeaway. `consensus` is [Consensus](https://consensus.app), which needs a key and is the only one that says how a study was run — `rct`, `meta-analysis` — with its sample size and a one-line finding.
131
+
132
+ Keys, and what each is worth:
133
+
134
+ | | |
135
+ | --- | --- |
136
+ | `openAlexPapers` | optional, free, from [OpenAlex authentication](https://help.openalex.org/api/authentication/) — sent as a bearer token, never in the url. Basic use is keyless; a key is ten times the daily budget, and heavier use is pay as you go |
137
+ | `semanticScholar` | optional, free, from [the API key form](https://www.semanticscholar.org/product/api#api-key-form) — emailed after review, so not the same day. Keyless is 1000 req/s *shared with every unauthenticated caller on the internet*, which in practice means routine 429s; a key is 1 req/s that is yours |
138
+ | `consensus` | required, from a [Consensus](https://consensus.app) account |
139
+
140
+ A 429 from the keyless Semantic Scholar pool is not a failure to design around — under `everyPaper` it is one source that did not answer while the others did.
141
+
142
+ **What a search cost** rides back with it. OpenAlex prices every call in its own envelope — `meta.cost_usd` — and that becomes `costMicroUsd` on the `PapersFound`, in micro-USD like every other price here. A source that does not price its own calls takes `costMicroUsd` as an option instead, which is what your plan charges per search:
143
+
144
+ ```ts
145
+ everyPaper([openAlexPapers({ apiKey }), consensus({ apiKey, costMicroUsd: 2500 })]);
146
+ ```
147
+
148
+ `everyPaper` sums what the sources that answered reported, and bills nothing for one that failed. `paper_search` declares `costOf`, so the number lands on the result as `costMicros` and `spentOn([...results])` adds a run up. Only sources that said are counted: a search with no price is unmeasured, not free, and those are different claims.
149
+
150
+ The tool returns `{ answer, results, costMicroUsd }` rather than a bare string. The model still reads only `answer` — `render()` has always unwrapped that shape — while `results` become the result's `sources`, one per paper, preferring an open PDF, then the source's own page, then `https://doi.org/…`.
151
+
152
+ ```ts
153
+ import { paperSearch } from "@spendgraph/tools";
154
+ import { consensus, openAlexPapers, semanticScholar } from "@spendgraph/tools/papers";
155
+
156
+ paperSearch({ papers: openAlexPapers({ apiKey }) });
157
+ paperSearch({ papers: semanticScholar({ apiKey, fieldsOfStudy: ["Medicine"] }), maxResults: 5 });
158
+ paperSearch({ papers: consensus({ apiKey, studyTypes: ["rct"], excludePreprints: true }) });
159
+ ```
160
+
161
+ All three hand back the same `Paper` — title, year, authors, venue, citations, DOI, an open PDF, a study type and sample size where the source knows them, a takeaway, and the abstract — so whatever reads one reads the others. The model chooses `query`, `year_min`, `year_max`, `min_citations` and `open_access`; the study filters, fields of study and journals are pinned on the searcher, so it cannot widen them. `openalex()` is the preset for the keyless case, and is `paperSearch({ papers: openAlexPapers(opts) })` with nothing added.
162
+
163
+ **Two or three sources at once** is `everySearch` in `@spendgraph/tools/search` and `everyPaper` in `@spendgraph/tools/papers`. Each takes a list, returns one searcher of the same type, and asks all of them at the same time.
164
+
165
+ ```ts
166
+ import { paperSearch, webSearch } from "@spendgraph/tools";
167
+ import { consensus, everyPaper, openAlexPapers, semanticScholar } from "@spendgraph/tools/papers";
168
+ import { everySearch, searxng, sonar } from "@spendgraph/tools/search";
169
+
170
+ paperSearch({ papers: everyPaper([consensus({ apiKey }), semanticScholar(), openAlexPapers()]) });
171
+ webSearch({ search: everySearch([sonar({ apiKey }), searxng({ baseUrl })]) });
172
+ ```
173
+
174
+ One tool, not three. `busgroup` is the other way to hold several providers — a bus each, `namespace: true`, and the model sees `consensus__paper_search` beside `s2__paper_search` — and it is the right shape when the buses do *different* things, because a name there has to say which one runs. These do the same thing: same ask in, same shape out. Interchangeable by construction is a fan-out, not a routing decision the model has any basis to make, and three namespaced copies spend three shortlist slots on one capability.
175
+
176
+ What the merge does with what comes back:
177
+
178
+ | | |
179
+ | --- | --- |
180
+ | order | round-robin — first from each source, then second from each, so no one corpus owns the top |
181
+ | the same paper twice | folded on DOI, else on the title with punctuation and case removed |
182
+ | a fold | fills gaps rather than dropping one: Consensus' `studyType` survives alongside Semantic Scholar's abstract, and the higher citation count wins |
183
+ | a source that fails | named in `failed`, and the rest still answer; `paper_search` prints `openalex did not answer: …` under the list |
184
+ | every source failing | the call fails, quoting what each one said |
185
+ | the bill | three providers per call, not one — a fan-out costs what its parts cost |
186
+
187
+ `everySearch` puts each answer under `# via <model>` so a quote stays attributable — `via` because a page's own text routinely carries an `#` heading of its own, and the marker has to be the one line that cannot be mistaken for content, unions the links, and sums all five token lines. It keeps `domains` only when *every* searcher is pinned, since one unpinned searcher means the answer is no longer restricted.
188
+
189
+ **`codeExec`** runs a short program and hands back what it printed — for work an arithmetic expression cannot state: sorting, filtering, joining, parsing. It takes a **required** `sandbox` and has no default, because a default would mean running model-written code in the process that called it. That is what `calculate` exists to avoid, and shipping one here would undo it.
190
+
191
+ Its `effect` is left unset on purpose: `readonly` would be a lie about arbitrary code and `destructive` a lie about sorting a list.
192
+
193
+ The sandbox is yours to supply, and the shape is small:
194
+
195
+ ```ts
196
+ import { codeExec, type Sandbox } from "@spendgraph/tools";
197
+
198
+ const sandbox: Sandbox = {
199
+ execute: async ({ code, language, timeoutMs }) => ({
200
+ ok: true,
201
+ stdout: "…",
202
+ stderr: "",
203
+ costMicroUsd: 0,
204
+ }),
205
+ };
206
+
207
+ codeExec({ sandbox, language: "python", timeoutMs: 10_000 });
208
+ ```
209
+
210
+ Anything satisfying `execute` works — a hosted sandbox, a container, a WASM runtime. A failed run comes back with `ok: false` rather than throwing, so the model reads `stderr` and corrects its own code instead of getting an opaque tool failure.
211
+
212
+ Commissioning a document agent used to live here as `writeDocument`. It now lives in
213
+ `@spendgraph/vigil/agents` as `moaAgent()`, because the work takes minutes and a tool has to
214
+ return now: a run parks on the agent and picks itself back up, rather than holding a process
215
+ open for ten minutes.
216
+
217
+ **`httpRequest`** takes a **required** allowlist and has no default. It is https only, refuses private, loopback, link-local and `.internal`/`.local` hosts **even if you list them**, and sets headers itself so a credential is never something the model can redirect. If the guard feels heavy, that is the correct amount of heavy: this is the one builtin that can exfiltrate. `allow: ["*"]` opens it to every public host, for the one case where the tool is a reader of the open web rather than a client of your own APIs; the private-address and https refusals still hold.
package/docs/bus.mdx ADDED
@@ -0,0 +1,90 @@
1
+ export const meta = {
2
+ title: "The bus — spendgraph docs",
3
+ description:
4
+ "toolbus holds them, picks the few worth sending, converts them per provider, and runs them without throwing. Selection is never applied for you, because hiding it would hide a cache invalidation.",
5
+ };
6
+
7
+ # The bus
8
+
9
+ ```ts
10
+ const bus = toolbus([lookup, calculate(), currentTime()], { limit: 6 });
11
+ ```
12
+
13
+ One object, four jobs: it **holds** the tools, **picks** the few worth sending, **converts** them to whatever the provider wants, and **runs** them without throwing.
14
+
15
+ ## Two names throw at construction
16
+
17
+ ```ts
18
+ toolbus([lookup, lookup]); // Error: Two tools are called "lookup_contract".
19
+ ```
20
+
21
+ Registering the second under the same key would leave `invoke` a coin flip. It fails at import instead.
22
+
23
+ The bus also warns — once, however many pairs — when two tools describe themselves almost identically. Nothing fails at runtime when they do; the model just picks wrong more often, which is exactly why it is worth saying out loud. Switch it off with `{ warnOnOverlap: false }` once a catalogue is large enough that the warning is noise rather than news.
24
+
25
+ ## Selection is never applied for you
26
+
27
+ ```ts
28
+ bus.anthropic() // every tool. 50 tools ≈ 7.5k tokens
29
+ bus.anthropic(bus.select(query)) // the shortlist
30
+ ```
31
+
32
+ The provider adapters send **everything** unless you narrow them, and that is deliberate. The `tools` array sits ahead of the messages in the cached prefix, so a list that changes every turn invalidates the system prompt along with it. Hiding a `select()` call inside `anthropic()` would hide that cost, so the shorter call is the expensive one on purpose.
33
+
34
+ In practice you want [`trace()`](/docs/tools/turns), which selects once and records what it offered.
35
+
36
+ ## What it answers
37
+
38
+ | | |
39
+ | --- | --- |
40
+ | `all()` | every tool, in declaration order |
41
+ | `get(name)` · `missing(names)` | one tool, and the names this bus lacks |
42
+ | `select(query, limit?)` | the working set for one request |
43
+ | `declarations(names?)` | the neutral shape, all or narrowed |
44
+ | `anthropic()` · `openai()` · `markdown()` | the same, per provider |
45
+ | `effectOf` · `destructive()` · `unannotated()` | what a gate reads |
46
+ | `invoke(name, args)` | runs one, validates first, never throws |
47
+ | `usage()` | how often each has been called |
48
+ | `step(result, i)` · `trace(query)` | what a rollout keeps |
49
+
50
+ ## `invoke` never throws
51
+
52
+ A tool that fails is an outcome the agent can react to, not an exception that ends the run. Arguments are validated first, so a model that invents an argument gets a `failed` result naming the problem rather than a handler crashing halfway through.
53
+
54
+ A name the bus does not have comes back failed **saying what was offered**, since the usual cause is a hallucinated name.
55
+
56
+ ```ts
57
+ const result = await bus.invoke("refund", { id: "o1" });
58
+ result.status; // "completed" | "failed"
59
+ ```
60
+
61
+ <Callout tone="trap" title="`invoke` never throws, so `status` is the only thing that says it failed">
62
+ A tool that throws comes back as a failed result the model reads and can act on, which is the point — but code that only looks at `output` sees an empty string and carries on. Check `status === "failed"` before believing a result.
63
+ </Callout>
64
+
65
+ ## Gating on effect
66
+
67
+ `destructive()` lists only the tools that **said** they are. Pair it with `unannotated()` before treating the rest as safe — see [Effects](/docs/tools/effects).
68
+
69
+ ## Usage is the tiebreaker
70
+
71
+ `usage()` counts what has actually run, and selection falls back on it when a query singles nothing out. A call that never reached the tool — a bad name, arguments that did not validate — is not counted, because it was not a use.
72
+
73
+ ## Nesting: one bus of buses
74
+
75
+ Past a few hundred tools, keyword ranking over one flat pool starts letting a coincidental match in an unrelated domain take a slot. `busgroup` gives each domain its own bus and routes between them:
76
+
77
+ ```ts
78
+ import { busgroup, toolbus } from "@spendgraph/tools";
79
+
80
+ const bus = busgroup([
81
+ { name: "finance", description: "Money: refunds, invoices, ledger and payouts.", bus: financeBus },
82
+ { name: "people", description: "Staff: leave, payslips and employment records.", bus: peopleBus },
83
+ ]);
84
+ ```
85
+
86
+ **It returns a bus.** Everything above works on it unchanged, a member's `bus` may itself be a group, and the model still sees a flat list of tools — grouping is a selection-time concern, never a wire-format one.
87
+
88
+ Routing runs in three steps. A query that clearly names a domain consults only that domain. A query that names none consults every member and re-ranks the merged offers, so nesting is never worse than flat. A query that matches nothing anywhere spreads across members rather than letting array order decide.
89
+
90
+ Duplicate tool names **across** buses throw, as do duplicate member names: silent shadowing would put `invoke` back to a coin flip.