@spendgraph/tools 0.8.0 → 0.8.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/docs/bogus.mdx +8 -8
- package/docs/builtins.mdx +23 -23
- package/docs/bus.mdx +7 -7
- package/docs/declaring.mdx +6 -6
- package/docs/effects.mdx +6 -6
- package/docs/overview.mdx +14 -12
- package/docs/selecting.mdx +10 -10
- package/docs/stored.mdx +8 -8
- package/docs/turns.mdx +5 -5
- package/package.json +4 -4
package/docs/bogus.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Bogus tools
|
|
2
|
+
title: "Bogus tools: spendgraph docs",
|
|
3
3
|
description:
|
|
4
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
5
|
};
|
|
@@ -22,13 +22,13 @@ Its own subpath, so nothing here reaches a production bundle by accident.
|
|
|
22
22
|
|
|
23
23
|
## For testing a pipeline, not a provider
|
|
24
24
|
|
|
25
|
-
Nothing here touches the network. No key, no rate limit, no flake, no bill
|
|
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
26
|
|
|
27
27
|
## Every answer is unknowable without the call
|
|
28
28
|
|
|
29
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
30
|
|
|
31
|
-
A model that answers from memory is therefore **caught**, not merely unhelpful
|
|
31
|
+
A model that answers from memory is therefore **caught**, not merely unhelpful: there is no training data to be accidentally right from.
|
|
32
32
|
|
|
33
33
|
## The receipt
|
|
34
34
|
|
|
@@ -44,13 +44,13 @@ import { receipt } from "@spendgraph/tools/bogus";
|
|
|
44
44
|
receipt("my_notes", "wifi"); // "bogus-my_notes-9e01b3"
|
|
45
45
|
```
|
|
46
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
|
|
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
48
|
|
|
49
49
|
## The twelve
|
|
50
50
|
|
|
51
51
|
| | | |
|
|
52
52
|
| --- | --- | --- |
|
|
53
|
-
| `my_notes` | the user's own notes
|
|
53
|
+
| `my_notes` | the user's own notes: passwords, plans, things written down | `readonly` |
|
|
54
54
|
| `my_calendar` | what is on for a given day | `readonly` |
|
|
55
55
|
| `my_recipes` | only what they saved themselves, not a cookbook | `readonly` |
|
|
56
56
|
| `dictionary` | definitions that are its own and not the ones in general use | `readonly` |
|
|
@@ -64,12 +64,12 @@ Grep an answer for `bogus-` and you know which tools actually reached it. The mo
|
|
|
64
64
|
| `ask_mum` | always answers, rarely the question asked, forty-one minutes | `destructive` |
|
|
65
65
|
|
|
66
66
|
<Callout tone="trap" title="They are fixtures, not fakes of anything real">
|
|
67
|
-
Every answer is unknowable without the call
|
|
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
68
|
</Callout>
|
|
69
69
|
|
|
70
70
|
## All three effects, on purpose
|
|
71
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
|
|
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
73
|
|
|
74
74
|
```ts
|
|
75
75
|
const bus = toolbus(bogusTools());
|
|
@@ -82,4 +82,4 @@ bus.unannotated(); // []
|
|
|
82
82
|
|
|
83
83
|
## What they are not
|
|
84
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
|
|
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.
|
package/docs/builtins.mdx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Builtins
|
|
2
|
+
title: "Builtins: spendgraph docs",
|
|
3
3
|
description:
|
|
4
|
-
"Twenty-one factories, none registered for you
|
|
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
5
|
};
|
|
6
6
|
|
|
7
7
|
# Builtins
|
|
8
8
|
|
|
9
|
-
Twenty-one, each a factory, **none registered for you
|
|
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
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { calculate, currentTime, escalate, finish, think, httpRequest } from "@spendgraph/tools";
|
|
@@ -34,7 +34,7 @@ const bus = toolbus([
|
|
|
34
34
|
| `dates()` | the same, asked in words, for a caller that can only send a question | local |
|
|
35
35
|
| `jsonQuery()` | one value out of a JSON document, by path | local |
|
|
36
36
|
| `httpRequest()` | a GET or POST to hosts you named | local |
|
|
37
|
-
| `extract()` | the text of a file at a URL
|
|
37
|
+
| `extract()` | the text of a file at a URL: pdf, docx, a page, md, txt | local |
|
|
38
38
|
| `codeExec()` | a short program, run wherever you say | sandbox |
|
|
39
39
|
| `publish()` | content out as a file at a public URL, through the app | key |
|
|
40
40
|
| `webSearch()` | a searched answer with its sources, from a searcher you pick | local or key |
|
|
@@ -94,12 +94,12 @@ const bus = toolbus(memories({ client: store }).tools);
|
|
|
94
94
|
```
|
|
95
95
|
|
|
96
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
|
|
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
98
|
</Callout>
|
|
99
99
|
|
|
100
100
|
## A few worth reading about
|
|
101
101
|
|
|
102
|
-
**`calculate`** parses the expression
|
|
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
103
|
|
|
104
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
105
|
|
|
@@ -107,13 +107,13 @@ It answers with the digits a double actually carries, so `0.1 + 0.2` is `0.3` an
|
|
|
107
107
|
|
|
108
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
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
|
|
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
111
|
|
|
112
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
113
|
|
|
114
|
-
**`dateMath`** is what `current_time` leads to. Calendar months clamp rather than overflow
|
|
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
115
|
|
|
116
|
-
**`jsonQuery`** reads one value by path
|
|
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
117
|
|
|
118
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
119
|
|
|
@@ -125,21 +125,21 @@ webSearch({ search: sonar({ apiKey, domains: ["gov.uk"] }), maxDepth: "pro" });
|
|
|
125
125
|
webSearch({ search: searxng({ baseUrl: "http://localhost:8080", language: "en" }), maxDepth: "deep" });
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
Both hand back the same shape
|
|
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
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
|
|
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
131
|
|
|
132
132
|
Keys, and what each is worth:
|
|
133
133
|
|
|
134
134
|
| | |
|
|
135
135
|
| --- | --- |
|
|
136
|
-
| `openAlexPapers` | optional, free, from [OpenAlex authentication](https://help.openalex.org/api/authentication/)
|
|
137
|
-
| `semanticScholar` | optional, free, from [the API key form](https://www.semanticscholar.org/product/api#api-key-form)
|
|
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
138
|
| `consensus` | required, from a [Consensus](https://consensus.app) account |
|
|
139
139
|
|
|
140
|
-
A 429 from the keyless Semantic Scholar pool is not a failure to design around
|
|
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
141
|
|
|
142
|
-
**What a search cost** rides back with it. OpenAlex prices every call in its own envelope
|
|
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
143
|
|
|
144
144
|
```ts
|
|
145
145
|
everyPaper([openAlexPapers({ apiKey }), consensus({ apiKey, costMicroUsd: 2500 })]);
|
|
@@ -147,7 +147,7 @@ everyPaper([openAlexPapers({ apiKey }), consensus({ apiKey, costMicroUsd: 2500 }
|
|
|
147
147
|
|
|
148
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
149
|
|
|
150
|
-
The tool returns `{ answer, results, costMicroUsd }` rather than a bare string. The model still reads only `answer
|
|
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
151
|
|
|
152
152
|
```ts
|
|
153
153
|
import { paperSearch } from "@spendgraph/tools";
|
|
@@ -158,7 +158,7 @@ paperSearch({ papers: semanticScholar({ apiKey, fieldsOfStudy: ["Medicine"] }),
|
|
|
158
158
|
paperSearch({ papers: consensus({ apiKey, studyTypes: ["rct"], excludePreprints: true }) });
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
All three hand back the same `Paper
|
|
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
162
|
|
|
163
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
164
|
|
|
@@ -171,22 +171,22 @@ paperSearch({ papers: everyPaper([consensus({ apiKey }), semanticScholar(), open
|
|
|
171
171
|
webSearch({ search: everySearch([sonar({ apiKey }), searxng({ baseUrl })]) });
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
One tool, not three. `busgroup` is the other way to hold several providers
|
|
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
175
|
|
|
176
176
|
What the merge does with what comes back:
|
|
177
177
|
|
|
178
178
|
| | |
|
|
179
179
|
| --- | --- |
|
|
180
|
-
| order | round-robin
|
|
180
|
+
| order | round-robin: first from each source, then second from each, so no one corpus owns the top |
|
|
181
181
|
| the same paper twice | folded on DOI, else on the title with punctuation and case removed |
|
|
182
182
|
| a fold | fills gaps rather than dropping one: Consensus' `studyType` survives alongside Semantic Scholar's abstract, and the higher citation count wins |
|
|
183
183
|
| a source that fails | named in `failed`, and the rest still answer; `paper_search` prints `openalex did not answer: …` under the list |
|
|
184
184
|
| every source failing | the call fails, quoting what each one said |
|
|
185
|
-
| the bill | three providers per call, not one
|
|
185
|
+
| the bill | three providers per call, not one, a fan-out costs what its parts cost |
|
|
186
186
|
|
|
187
|
-
`everySearch` puts each answer under `# via <model>` so a quote stays attributable
|
|
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
188
|
|
|
189
|
-
**`codeExec`** runs a short program and hands back what it printed
|
|
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
190
|
|
|
191
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
192
|
|
|
@@ -207,7 +207,7 @@ const sandbox: Sandbox = {
|
|
|
207
207
|
codeExec({ sandbox, language: "python", timeoutMs: 10_000 });
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
-
Anything satisfying `execute` works
|
|
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
211
|
|
|
212
212
|
Commissioning a document agent used to live here as `writeDocument`. It now lives in
|
|
213
213
|
`@spendgraph/vigil/agents` as `moaAgent()`, because the work takes minutes and a tool has to
|
package/docs/bus.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "The bus
|
|
2
|
+
title: "The bus: spendgraph docs",
|
|
3
3
|
description:
|
|
4
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
5
|
};
|
|
@@ -20,7 +20,7 @@ toolbus([lookup, lookup]); // Error: Two tools are called "lookup_contract".
|
|
|
20
20
|
|
|
21
21
|
Registering the second under the same key would leave `invoke` a coin flip. It fails at import instead.
|
|
22
22
|
|
|
23
|
-
The bus also warns
|
|
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
24
|
|
|
25
25
|
## Selection is never applied for you
|
|
26
26
|
|
|
@@ -31,7 +31,7 @@ bus.anthropic(bus.select(query)) // the shortlist
|
|
|
31
31
|
|
|
32
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
33
|
|
|
34
|
-
In practice you want [`trace()`](/
|
|
34
|
+
In practice you want [`trace()`](/spendgraph/tools/turns), which selects once and records what it offered.
|
|
35
35
|
|
|
36
36
|
## What it answers
|
|
37
37
|
|
|
@@ -59,16 +59,16 @@ result.status; // "completed" | "failed"
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
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
|
|
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
63
|
</Callout>
|
|
64
64
|
|
|
65
65
|
## Gating on effect
|
|
66
66
|
|
|
67
|
-
`destructive()` lists only the tools that **said** they are. Pair it with `unannotated()` before treating the rest as safe
|
|
67
|
+
`destructive()` lists only the tools that **said** they are. Pair it with `unannotated()` before treating the rest as safe. See [Effects](/spendgraph/tools/effects).
|
|
68
68
|
|
|
69
69
|
## Usage is the tiebreaker
|
|
70
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
|
|
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
72
|
|
|
73
73
|
## Nesting: one bus of buses
|
|
74
74
|
|
|
@@ -83,7 +83,7 @@ const bus = busgroup([
|
|
|
83
83
|
]);
|
|
84
84
|
```
|
|
85
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
|
|
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
87
|
|
|
88
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
89
|
|
package/docs/declaring.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Declaring a tool
|
|
2
|
+
title: "Declaring a tool: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Write `as const` and the handler types itself. Every check `tool` makes is something a provider either rejects opaquely or accepts and behaves oddly about.",
|
|
5
5
|
};
|
|
@@ -21,7 +21,7 @@ const lookup = tool({
|
|
|
21
21
|
|
|
22
22
|
## Write `as const` and the handler types itself
|
|
23
23
|
|
|
24
|
-
`ref` arrives a string, `limit` a number, `mode` narrowed to its two options, and the optional ones optional. Rename an argument and the handler stops compiling
|
|
24
|
+
`ref` arrives a string, `limit` a number, `mode` narrowed to its two options, and the optional ones optional. Rename an argument and the handler stops compiling: rather than being handed `undefined` halfway through an agent loop.
|
|
25
25
|
|
|
26
26
|
Without `as const`, inference falls back to nothing **silently**. That is the failure the package's own typecheck test exists to catch.
|
|
27
27
|
|
|
@@ -32,11 +32,11 @@ Arguments are validated, then filled in and coerced, before `run` sees them:
|
|
|
32
32
|
- a **declared default** applies when the argument is absent;
|
|
33
33
|
- a value arrives at the **type its declaration promises**.
|
|
34
34
|
|
|
35
|
-
Providers hand tool arguments over as JSON a model wrote, so `"limit": "7"` is ordinary. It validates
|
|
35
|
+
Providers hand tool arguments over as JSON a model wrote, so `"limit": "7"` is ordinary. It validates, the checker reads it through the same coercion, and it reaches `run` as the number `7`. `"loud": "false"` reaches it as `false`, not as a non-empty string that would make `if (args.loud)` run the branch the model asked it not to.
|
|
36
36
|
|
|
37
37
|
## `aliases` are matched, never sent
|
|
38
38
|
|
|
39
|
-
The description is what the model reads. `aliases` are what [selection](/
|
|
39
|
+
The description is what the model reads. `aliases` are what [selection](/spendgraph/tools/selecting) matches on, and they never leave the process:
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
tool({
|
|
@@ -68,7 +68,7 @@ Every check `tool` makes is something a provider either rejects opaquely or acce
|
|
|
68
68
|
They fire at import, not on the first loop in production.
|
|
69
69
|
|
|
70
70
|
<Callout tone="trap" title="Without `as const`, inference gives up quietly">
|
|
71
|
-
Drop it and the handler's argument type falls back to `Record<string, unknown
|
|
71
|
+
Drop it and the handler's argument type falls back to `Record<string, unknown>`, no error, no red squiggle, just every field typed `unknown` from then on. It is checked by a typecheck test in the package for exactly that reason.
|
|
72
72
|
</Callout>
|
|
73
73
|
|
|
74
74
|
## What a result carries
|
|
@@ -80,6 +80,6 @@ result.error; // why, when it failed
|
|
|
80
80
|
result.latencyMs;
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
A tool whose **return value** cannot be serialised
|
|
83
|
+
A tool whose **return value** cannot be serialised, a circular object, a `BigInt`, is still a tool that **ran**. It comes back completed, carrying `[not recordable: …]`, rather than failed: reporting a completed action as a failure invites the agent to do it again.
|
|
84
84
|
|
|
85
85
|
A thrown string keeps its message. Anything can be thrown, and reading only `Error.message` discards the one thing the tool said about itself.
|
package/docs/effects.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Effects
|
|
2
|
+
title: "Effects: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Three values, not a taxonomy. There is no default, and unset is not readonly: a default would quietly label every tool written before this existed as safe.",
|
|
5
5
|
};
|
|
@@ -20,7 +20,7 @@ Without it, `issue_refund` and `lookup_clause` are the same thing to a bus: two
|
|
|
20
20
|
| `idempotent` | has an effect, but twice is the same as once |
|
|
21
21
|
| `destructive` | neither, so never retry it on its own |
|
|
22
22
|
|
|
23
|
-
Three values, not a taxonomy
|
|
23
|
+
Three values, not a taxonomy: anything finer does not get filled in accurately.
|
|
24
24
|
|
|
25
25
|
## There is no default, and unset is not `readonly`
|
|
26
26
|
|
|
@@ -32,18 +32,18 @@ bus.destructive() // ["issue_refund"]
|
|
|
32
32
|
bus.unannotated() // ["send_email"]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`destructive()` returns only the tools that said so, so a gate built on it alone lets an unlabelled one through. **`unannotated()` is how you find those
|
|
35
|
+
`destructive()` returns only the tools that said so, so a gate built on it alone lets an unlabelled one through. **`unannotated()` is how you find those**: assert it is empty in a test and a new tool cannot join the bus unlabelled.
|
|
36
36
|
|
|
37
37
|
<Callout tone="trap" title="`readonly` is not the same as safe">
|
|
38
|
-
Effect says what a call does outside the process; `trust` says whether what it returns was written by somebody outside. A web fetch is `readonly` and is also how an injected instruction arrives. A guard that wants "no destructive call after untrusted content came in" has to gate on `trust: "external"
|
|
38
|
+
Effect says what a call does outside the process; `trust` says whether what it returns was written by somebody outside. A web fetch is `readonly` and is also how an injected instruction arrives. A guard that wants "no destructive call after untrusted content came in" has to gate on `trust: "external"`: the alternative is gating on the tool's *name*, which a rename turns off silently, and in the permissive direction.
|
|
39
39
|
</Callout>
|
|
40
40
|
|
|
41
41
|
## It survives selection
|
|
42
42
|
|
|
43
43
|
The effect rides along on `declarations()` and survives `select()`, which is where a gate reads it. Every provider shape drops it: no API has a field for it, and an unknown key is either rejected or ignored.
|
|
44
44
|
|
|
45
|
-
A turn answers about the tools **it** was offered
|
|
45
|
+
A turn answers about the tools **it** was offered: `effectOf` is `undefined` both for a tool that never said and for one this turn never saw. A gate reading it decides what unknown means for it.
|
|
46
46
|
|
|
47
47
|
## Three builtins leave it unset on purpose
|
|
48
48
|
|
|
49
|
-
`http_request` is a GET or a POST depending on an argument, and one label cannot be true of both. `escalate` runs your `onEscalate`, and paging someone twice is not the same as paging them once. `code_exec` runs whatever the model wrote, which is neither safe nor known
|
|
49
|
+
`http_request` is a GET or a POST depending on an argument, and one label cannot be true of both. `escalate` runs your `onEscalate`, and paging someone twice is not the same as paging them once. `code_exec` runs whatever the model wrote, which is neither safe nor known, and `undefined` says exactly that, where either label would lie.
|
package/docs/overview.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Tools
|
|
2
|
+
title: "Tools: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Declare a tool once, offer the right few, and record what was called. A description and a function in one object, so a rename cannot become a runtime failure.",
|
|
5
5
|
};
|
|
@@ -8,12 +8,14 @@ export const meta = {
|
|
|
8
8
|
|
|
9
9
|
Fifty tools is 7,500 tokens spent before the user has typed anything, and a model given fifty picks worse than a model given five.
|
|
10
10
|
|
|
11
|
-
Declare a tool once, offer the right few, and record what was called
|
|
11
|
+
Declare a tool once, offer the right few, and record what was called: because a model that says it checked the ledger and did not is a claim only the steps can disprove.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
14
|
npm install @spendgraph/tools
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
<ToolShortlist />
|
|
18
|
+
|
|
17
19
|
Two things to learn, and twenty-two builtins you can ignore until you want one.
|
|
18
20
|
|
|
19
21
|
```ts
|
|
@@ -44,7 +46,7 @@ turn.record(); // { offeredTools, steps }
|
|
|
44
46
|
|
|
45
47
|
## Why one object
|
|
46
48
|
|
|
47
|
-
A description the model reads and a function you wrote, in **one object**. Splitting them
|
|
49
|
+
A description the model reads and a function you wrote, in **one object**. Splitting them, a schema here, a handler there, matched by string, is how a rename becomes a runtime failure mid-loop.
|
|
48
50
|
|
|
49
51
|
The description is the biggest lever on whether the model calls it correctly, and worth iterating on more than the code is.
|
|
50
52
|
|
|
@@ -59,18 +61,18 @@ await prompt.call(values, ({ messages, turn }) => llm.call(messages, { tools: tu
|
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
<Callout tone="trap" title="Declaring a tool is not offering it">
|
|
62
|
-
`toolbus()` holds everything your app can do; what the model sees each turn is a shortlist. Selection is never applied for you
|
|
64
|
+
`toolbus()` holds everything your app can do; what the model sees each turn is a shortlist. Selection is never applied for you: hand the model the whole bus and you are paying for every description on every turn, and making it worse at choosing.
|
|
63
65
|
</Callout>
|
|
64
66
|
|
|
65
67
|
## Where to go next
|
|
66
68
|
|
|
67
69
|
| | |
|
|
68
70
|
| --- | --- |
|
|
69
|
-
| [Declaring a tool](/
|
|
70
|
-
| [A stored tool](/
|
|
71
|
-
| [Effects](/
|
|
72
|
-
| [The bus](/
|
|
73
|
-
| [Selecting](/
|
|
74
|
-
| [A turn](/
|
|
75
|
-
| [Builtins](/
|
|
76
|
-
| [Bogus tools](/
|
|
71
|
+
| [Declaring a tool](/spendgraph/tools/declaring) | args, `as const`, and what is checked at import |
|
|
72
|
+
| [A stored tool](/spendgraph/tools/stored) | wording in the dashboard, handler in code |
|
|
73
|
+
| [Effects](/spendgraph/tools/effects) | what running one does to the world outside |
|
|
74
|
+
| [The bus](/spendgraph/tools/bus) | holds, picks, converts, runs, and nests |
|
|
75
|
+
| [Selecting](/spendgraph/tools/selecting) | the shortlist, and why it is a shortlist |
|
|
76
|
+
| [A turn](/spendgraph/tools/turns) | offered, called, and what the rollout keeps |
|
|
77
|
+
| [Builtins](/spendgraph/tools/builtins) | thirteen factories, none registered for you |
|
|
78
|
+
| [Bogus tools](/spendgraph/tools/bogus) | twelve mocks for testing the pipeline, not the provider |
|
package/docs/selecting.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Selecting tools
|
|
2
|
+
title: "Selecting tools: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Fifty tools is 7.5k tokens before the user has said anything. `limit` is an accuracy budget, not a token one, and selection is keyword and usage on purpose.",
|
|
5
5
|
};
|
|
@@ -22,23 +22,23 @@ Holding tools in one place is what makes selection possible. Fifty tools at roug
|
|
|
22
22
|
|
|
23
23
|
## `limit` is an accuracy budget
|
|
24
24
|
|
|
25
|
-
More tools in front of a model makes it worse at picking the right one **regardless of context length**. Measured shortlists cluster around five to eight. Pinned tools count against it
|
|
25
|
+
More tools in front of a model makes it worse at picking the right one **regardless of context length**. Measured shortlists cluster around five to eight. Pinned tools count against it: what is in front of the model is what costs accuracy, whoever put it there.
|
|
26
26
|
|
|
27
|
-
A limit that is `NaN` is refused rather than clamped: `Math.max(1, NaN)` is `NaN`, and every length compares false against it, so the model would be offered **nothing
|
|
27
|
+
A limit that is `NaN` is refused rather than clamped: `Math.max(1, NaN)` is `NaN`, and every length compares false against it, so the model would be offered **nothing**, the one outcome selection exists to avoid.
|
|
28
28
|
|
|
29
29
|
## Keyword and usage, deliberately
|
|
30
30
|
|
|
31
31
|
No embedding provider, no extra call, no latency. It is also the baseline that says what embeddings would buy.
|
|
32
32
|
|
|
33
|
-
Relevance comes before usage: at a shortlist of six, three previously-used tools take half the slots and push out the tool the query needs. Usage earns its place as the tiebreaker among equal matches, and as the fallback when nothing matches
|
|
33
|
+
Relevance comes before usage: at a shortlist of six, three previously-used tools take half the slots and push out the tool the query needs. Usage earns its place as the tiebreaker among equal matches, and as the fallback when nothing matches, the case where an agent would otherwise be handed nothing.
|
|
34
34
|
|
|
35
|
-
Words are found in **any script**. A query with no ASCII letters used to produce no terms at all, so every request fell through to "the first six declared"
|
|
35
|
+
Words are found in **any script**. A query with no ASCII letters used to produce no terms at all, so every request fell through to "the first six declared", the same six for a Japanese question as for a Russian one.
|
|
36
36
|
|
|
37
37
|
## How a tool scores
|
|
38
38
|
|
|
39
39
|
Whole words, never substrings. A term matching inside a longer word is noise, not signal: `me` sits inside `statement`, and at a few hundred tools that alone puts half the catalogue in every shortlist.
|
|
40
40
|
|
|
41
|
-
Where the word appears decides its weight
|
|
41
|
+
Where the word appears decides its weight: **name beats alias beats description**. A tool named `refund` is a stronger answer to a question about refunds than one that merely mentions the word in a paragraph.
|
|
42
42
|
|
|
43
43
|
Then each term is weighted by how **rare** it is across the bus. A word one tool in two thousand uses says far more than one four hundred of them share, and without that every term counts the same and common vocabulary decides the shortlist.
|
|
44
44
|
|
|
@@ -56,15 +56,15 @@ tool({
|
|
|
56
56
|
});
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
They are matched, never sent
|
|
59
|
+
They are matched, never sent: every provider shape drops them, exactly as it drops `effect`. Unlike an embedding, a bad match is fixed by editing one line.
|
|
60
60
|
|
|
61
61
|
<Callout tone="trap" title="Pinned tools spend the budget too">
|
|
62
|
-
`limit` counts pinned tools, so a shortlist of 5 with `finish` and `escalate` pinned leaves three slots for the query. A tool that needs a partner is dropped rather than offered alone
|
|
62
|
+
`limit` counts pinned tools, so a shortlist of 5 with `finish` and `escalate` pinned leaves three slots for the query. A tool that needs a partner is dropped rather than offered alone, an agent told to prefer `recall` and handed only `deep_recall` will reach for the one it does not have.
|
|
63
63
|
</Callout>
|
|
64
64
|
|
|
65
65
|
## Past a few hundred tools
|
|
66
66
|
|
|
67
|
-
Ranking, not speed, is what gives out first: selection stays near a millisecond at two thousand tools, but a coincidental match in an unrelated domain starts taking slots. [`busgroup`](/
|
|
67
|
+
Ranking, not speed, is what gives out first: selection stays near a millisecond at two thousand tools, but a coincidental match in an unrelated domain starts taking slots. [`busgroup`](/spendgraph/tools/bus) gives each domain its own bus and routes between them.
|
|
68
68
|
|
|
69
69
|
## `missing` catches a silent failure
|
|
70
70
|
|
|
@@ -76,4 +76,4 @@ bus.missing(["refund", "escalate"]); // the names this bus does not have
|
|
|
76
76
|
|
|
77
77
|
## Two tools that describe themselves the same way
|
|
78
78
|
|
|
79
|
-
Two near-identical descriptions are worse than one tool: the model cannot tell them apart and picks wrong more often, with nothing failing. The bus says so at construction, once, however many pairs there are
|
|
79
|
+
Two near-identical descriptions are worse than one tool: the model cannot tell them apart and picks wrong more often, with nothing failing. The bus says so at construction, once, however many pairs there are, a line each buries whatever else the process said at startup and teaches people to switch the check off.
|
package/docs/stored.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "A stored tool
|
|
2
|
+
title: "A stored tool: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"The dashboard owns the description, your code owns the arguments and the handler, and bindTool checks the two against each other before the model ever sees it.",
|
|
5
5
|
};
|
|
@@ -19,7 +19,7 @@ const lookup = await bindTool(sg, {
|
|
|
19
19
|
});
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
No `description` in that call. That is the point
|
|
22
|
+
No `description` in that call. That is the point, one source for the string the model reads, and it is the one you can edit without a release.
|
|
23
23
|
|
|
24
24
|
## Who owns what
|
|
25
25
|
|
|
@@ -31,13 +31,13 @@ No `description` in that call. That is the point — one source for the string t
|
|
|
31
31
|
pinned a deploy
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Arguments stay in code because the handler's parameter types are inferred from them. Pull those from the server and every `run` is handed a `Record<string, unknown>`, which gives up the one guarantee [declaring a tool](/
|
|
34
|
+
Arguments stay in code because the handler's parameter types are inferred from them. Pull those from the server and every `run` is handed a `Record<string, unknown>`, which gives up the one guarantee [declaring a tool](/spendgraph/tools/declaring) exists to provide.
|
|
35
35
|
|
|
36
36
|
Write `as const` here exactly as you would for `tool()`. Without it inference falls back to nothing, and the drift check below becomes the only thing still catching a rename.
|
|
37
37
|
|
|
38
38
|
## Drift fails at bind, not mid-loop
|
|
39
39
|
|
|
40
|
-
The stored arguments and the declared ones are compared on the way through, by **name, type and requiredness
|
|
40
|
+
The stored arguments and the declared ones are compared on the way through, by **name, type and requiredness**: the three a handler is written against.
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
43
|
import { ToolDriftError, ToolNotStoredError } from "@spendgraph/tools";
|
|
@@ -58,21 +58,21 @@ try {
|
|
|
58
58
|
"mode" is stored but not declared here
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
A description or a bound that differs is **not** drift
|
|
61
|
+
A description or a bound that differs is **not** drift: that is the dashboard being edited, which is the whole feature. A renamed argument is a handler about to be passed `undefined` halfway through an agent loop, so it throws.
|
|
62
62
|
|
|
63
63
|
<Callout tone="warn" title="Bind at startup, not per request">
|
|
64
|
-
Every `bindTool` is a round trip to the API. Bind once when the process starts and hold the result, the same as you would a database connection
|
|
64
|
+
Every `bindTool` is a round trip to the API. Bind once when the process starts and hold the result, the same as you would a database connection, a bind inside a request handler puts a network hop in front of every model call.
|
|
65
65
|
</Callout>
|
|
66
66
|
|
|
67
67
|
<Callout tone="trap" title="A rename in the dashboard is a handler passed `undefined`">
|
|
68
|
-
The wording is editable without a deploy; the argument names are not. `bindTool` compares them at bind time and refuses
|
|
68
|
+
The wording is editable without a deploy; the argument names are not. `bindTool` compares them at bind time and refuses: a deploy that fails is recoverable, and the same rename noticed mid-loop is a handler halfway through somebody's refund reading an argument that is not there.
|
|
69
69
|
</Callout>
|
|
70
70
|
|
|
71
71
|
## When nothing is stored yet
|
|
72
72
|
|
|
73
73
|
`ToolNotStoredError` is the ordinary case of "you have not written this one in the dashboard yet", not an exotic failure. It exists so a 404 from three layers down arrives as a sentence naming the tool.
|
|
74
74
|
|
|
75
|
-
Create the row first
|
|
75
|
+
Create the row first, `sg.tools.create` on the [client](/spendgraph/sdk/client), or the Tools page in the dashboard, then bind to it.
|
|
76
76
|
|
|
77
77
|
## It is an ordinary tool afterwards
|
|
78
78
|
|
package/docs/turns.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "A turn
|
|
2
|
+
title: "A turn: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"A turn is selected once and remembers what it offered. Without that a record cannot tell 'chose not to' from 'was never offered', which are opposite bugs.",
|
|
5
5
|
};
|
|
@@ -19,7 +19,7 @@ A turn is **selected once** and remembers what it offered. A trace cannot disagr
|
|
|
19
19
|
|
|
20
20
|
## Offered is recorded, not just called
|
|
21
21
|
|
|
22
|
-
Which tools were offered is recorded as well as which were called. Without it a record cannot tell **"chose not to"** from **"was never offered"
|
|
22
|
+
Which tools were offered is recorded as well as which were called. Without it a record cannot tell **"chose not to"** from **"was never offered"**, which are opposite bugs, and look identical in a log that only lists calls.
|
|
23
23
|
|
|
24
24
|
## What a turn answers
|
|
25
25
|
|
|
@@ -34,17 +34,17 @@ Which tools were offered is recorded as well as which were called. Without it a
|
|
|
34
34
|
|
|
35
35
|
## Never throwing is the point
|
|
36
36
|
|
|
37
|
-
A tool that fails is an **outcome the agent can react to** and a step worth recording, not an exception that ends the run
|
|
37
|
+
A tool that fails is an **outcome the agent can react to** and a step worth recording, not an exception that ends the run, the same argument `status` and `error` exist on a rollout for.
|
|
38
38
|
|
|
39
39
|
A tool the bus does not have is a failure that says what was offered, since the usual cause is a hallucinated name.
|
|
40
40
|
|
|
41
41
|
<Callout tone="trap" title="`undefined` means two different things">
|
|
42
|
-
A tool that was offered and stayed quiet, and a tool this turn never offered, both answer `undefined`. `offeredTools` is what tells them apart
|
|
42
|
+
A tool that was offered and stayed quiet, and a tool this turn never offered, both answer `undefined`. `offeredTools` is what tells them apart, which is the difference between a model that declined to look something up and a model that could not.
|
|
43
43
|
</Callout>
|
|
44
44
|
|
|
45
45
|
## Usage drives the tiebreaker
|
|
46
46
|
|
|
47
|
-
`bus.usage()` counts what has actually been called, which is what selection falls back on when a query singles out nothing. A call that never reached the tool
|
|
47
|
+
`bus.usage()` counts what has actually been called, which is what selection falls back on when a query singles out nothing. A call that never reached the tool: a name that does not exist, arguments that did not validate, is not counted, because it was not a use.
|
|
48
48
|
|
|
49
49
|
## With a prompt
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/tools",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Declare a tool once, offer the right few, and record what was called.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -61,14 +61,14 @@
|
|
|
61
61
|
"README.md"
|
|
62
62
|
],
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@spendgraph/sdk": "^0.8.
|
|
64
|
+
"@spendgraph/sdk": "^0.8.1",
|
|
65
65
|
"jszip": "^3.10.2",
|
|
66
66
|
"unpdf": "^1.8.1"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@locusgraph/client": "^0.8.1",
|
|
70
|
-
"@spendgraph/config": "0.8.
|
|
71
|
-
"@spendgraph/prompt": "^0.8.
|
|
70
|
+
"@spendgraph/config": "0.8.1",
|
|
71
|
+
"@spendgraph/prompt": "^0.8.1",
|
|
72
72
|
"pdf-lib": "^1.17.1",
|
|
73
73
|
"typescript": "^5"
|
|
74
74
|
},
|