@spendgraph/tools 0.5.0 → 0.7.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.
- package/LICENSE +202 -0
- package/README.md +26 -258
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.js +1 -0
- package/dist/agents/moa/design.js +5 -0
- package/dist/agents/moa/http.js +1 -0
- package/dist/{builtin/write-document/write-document.d.ts → agents/moa/index.d.ts} +7 -2
- package/dist/agents/moa/index.js +1 -0
- package/dist/{builtin/write-document/commission.d.ts → agents/moa/moa.d.ts} +15 -15
- package/dist/agents/moa/moa.js +13 -0
- package/dist/{builtin/write-document → agents/moa}/types.d.ts +9 -9
- package/dist/agents/moa/types.js +0 -0
- package/dist/bogus/ask-mum.d.ts +5 -0
- package/dist/bogus/ask-mum.js +1 -0
- package/dist/bogus/book-taxi.d.ts +9 -0
- package/dist/bogus/book-taxi.js +1 -0
- package/dist/bogus/bus.d.ts +8 -0
- package/dist/bogus/bus.js +1 -0
- package/dist/bogus/calculator.d.ts +14 -0
- package/dist/bogus/calculator.js +1 -0
- package/dist/bogus/dictionary.d.ts +6 -0
- package/dist/bogus/dictionary.js +1 -0
- package/dist/bogus/feed-cat.d.ts +8 -0
- package/dist/bogus/feed-cat.js +1 -0
- package/dist/bogus/football-score.d.ts +6 -0
- package/dist/bogus/football-score.js +1 -0
- package/dist/bogus/gold-price.d.ts +7 -0
- package/dist/bogus/gold-price.js +1 -0
- package/dist/bogus/index.d.ts +14 -0
- package/dist/bogus/index.js +1 -0
- package/dist/bogus/my-calendar.d.ts +6 -0
- package/dist/bogus/my-calendar.js +1 -0
- package/dist/bogus/my-notes.d.ts +6 -0
- package/dist/bogus/my-notes.js +1 -0
- package/dist/bogus/my-recipes.d.ts +6 -0
- package/dist/bogus/my-recipes.js +1 -0
- package/dist/bogus/order-pizza.d.ts +12 -0
- package/dist/bogus/order-pizza.js +1 -0
- package/dist/bogus/prayer-times.d.ts +6 -0
- package/dist/bogus/prayer-times.js +1 -0
- package/dist/bogus/receipt.d.ts +8 -0
- package/dist/bogus/receipt.js +1 -0
- package/dist/builtin/calculate/calculate.d.ts +2 -0
- package/dist/builtin/calculate/calculate.js +1 -21
- package/dist/builtin/calculate/exact.d.ts +16 -0
- package/dist/builtin/calculate/exact.js +1 -0
- package/dist/builtin/calculate/index.d.ts +2 -1
- package/dist/builtin/calculate/index.js +1 -2
- package/dist/builtin/calculate/parse.js +1 -137
- package/dist/builtin/clock/clock.js +1 -61
- package/dist/builtin/clock/index.js +1 -1
- package/dist/builtin/code-exec/code-exec.d.ts +42 -0
- package/dist/builtin/code-exec/code-exec.js +1 -0
- package/dist/builtin/code-exec/index.d.ts +1 -0
- package/dist/builtin/code-exec/index.js +1 -0
- package/dist/builtin/date/date.d.ts +1 -1
- package/dist/builtin/date/date.js +1 -41
- package/dist/builtin/date/index.d.ts +1 -0
- package/dist/builtin/date/index.js +1 -2
- package/dist/builtin/date/shift.d.ts +1 -1
- package/dist/builtin/date/shift.js +1 -47
- package/dist/builtin/date/words.d.ts +13 -0
- package/dist/builtin/date/words.js +1 -0
- package/dist/builtin/decline/decline.d.ts +49 -0
- package/dist/builtin/decline/decline.js +1 -0
- package/dist/builtin/decline/index.d.ts +1 -0
- package/dist/builtin/decline/index.js +1 -0
- package/dist/builtin/escalate/escalate.d.ts +19 -0
- package/dist/builtin/escalate/escalate.js +1 -20
- package/dist/builtin/escalate/index.d.ts +1 -1
- package/dist/builtin/escalate/index.js +1 -1
- package/dist/builtin/extract/extract.d.ts +25 -0
- package/dist/builtin/extract/extract.js +1 -0
- package/dist/builtin/extract/index.d.ts +3 -0
- package/dist/builtin/extract/index.js +1 -0
- package/dist/builtin/extract/kinds.d.ts +6 -0
- package/dist/builtin/extract/kinds.js +1 -0
- package/dist/builtin/extract/readers.d.ts +7 -0
- package/dist/builtin/extract/readers.js +9 -0
- package/dist/builtin/finish/finish.d.ts +4 -0
- package/dist/builtin/finish/finish.js +1 -26
- package/dist/builtin/finish/index.js +1 -1
- package/dist/builtin/http/allow.d.ts +2 -0
- package/dist/builtin/http/allow.js +1 -37
- package/dist/builtin/http/hops.d.ts +10 -0
- package/dist/builtin/http/hops.js +1 -0
- package/dist/builtin/http/http.d.ts +9 -1
- package/dist/builtin/http/http.js +1 -41
- package/dist/builtin/http/index.d.ts +2 -0
- package/dist/builtin/http/index.js +1 -2
- package/dist/builtin/http/retry-after.d.ts +14 -0
- package/dist/builtin/http/retry-after.js +1 -0
- package/dist/builtin/index.d.ts +11 -6
- package/dist/builtin/index.js +1 -11
- package/dist/builtin/json/index.d.ts +1 -0
- package/dist/builtin/json/index.js +1 -2
- package/dist/builtin/json/json.js +1 -37
- package/dist/builtin/json/parse.d.ts +15 -0
- package/dist/builtin/json/parse.js +1 -0
- package/dist/builtin/json/path.js +1 -56
- package/dist/builtin/notes/index.d.ts +1 -0
- package/dist/builtin/notes/index.js +1 -0
- package/dist/builtin/notes/notes.d.ts +45 -0
- package/dist/builtin/notes/notes.js +2 -0
- package/dist/builtin/openalex/api.d.ts +22 -0
- package/dist/builtin/openalex/api.js +1 -0
- package/dist/builtin/openalex/index.d.ts +4 -0
- package/dist/builtin/openalex/index.js +1 -0
- package/dist/builtin/openalex/openalex.d.ts +23 -0
- package/dist/builtin/openalex/openalex.js +1 -0
- package/dist/builtin/openalex/render.d.ts +2 -0
- package/dist/builtin/openalex/render.js +4 -0
- package/dist/builtin/polite.d.ts +14 -0
- package/dist/builtin/polite.js +1 -0
- package/dist/builtin/publish/index.d.ts +1 -0
- package/dist/builtin/publish/index.js +1 -0
- package/dist/builtin/publish/publish.d.ts +46 -0
- package/dist/builtin/publish/publish.js +1 -0
- package/dist/builtin/think/index.js +1 -1
- package/dist/builtin/think/think.js +1 -12
- package/dist/builtin/web-search/index.js +1 -1
- package/dist/builtin/web-search/web-search.d.ts +10 -30
- package/dist/builtin/web-search/web-search.js +1 -103
- package/dist/builtin/wikidata/api.d.ts +40 -0
- package/dist/builtin/wikidata/api.js +1 -0
- package/dist/builtin/wikidata/index.d.ts +5 -0
- package/dist/builtin/wikidata/index.js +1 -0
- package/dist/builtin/wikidata/render.d.ts +13 -0
- package/dist/builtin/wikidata/render.js +2 -0
- package/dist/builtin/wikidata/wikidata.d.ts +21 -0
- package/dist/builtin/wikidata/wikidata.js +1 -0
- package/dist/bus/args.d.ts +11 -0
- package/dist/bus/args.js +1 -0
- package/dist/bus/bus.d.ts +23 -1
- package/dist/bus/bus.js +1 -49
- package/dist/bus/group.d.ts +21 -0
- package/dist/bus/group.js +1 -0
- package/dist/bus/index.d.ts +4 -0
- package/dist/bus/index.js +1 -2
- package/dist/bus/invoke.d.ts +1 -1
- package/dist/bus/invoke.js +3 -43
- package/dist/bus/namespace.d.ts +21 -0
- package/dist/bus/namespace.js +1 -0
- package/dist/bus/once.d.ts +32 -0
- package/dist/bus/once.js +1 -0
- package/dist/bus/only.d.ts +29 -0
- package/dist/bus/only.js +0 -0
- package/dist/bus/options.d.ts +31 -0
- package/dist/bus/options.js +0 -1
- package/dist/bus/output.d.ts +26 -0
- package/dist/bus/output.js +2 -0
- package/dist/bus/overlap.js +1 -31
- package/dist/bus/score.d.ts +11 -9
- package/dist/bus/score.js +1 -12
- package/dist/bus/select.d.ts +2 -17
- package/dist/bus/select.js +1 -44
- package/dist/bus/spent.d.ts +16 -0
- package/dist/bus/spent.js +1 -0
- package/dist/bus/step.js +1 -11
- package/dist/bus/terms.d.ts +25 -0
- package/dist/bus/terms.js +1 -0
- package/dist/bus/trace.d.ts +14 -1
- package/dist/bus/trace.js +1 -22
- package/dist/index.d.ts +16 -4
- package/dist/index.js +1 -3
- package/dist/internals.d.ts +9 -2
- package/dist/internals.js +1 -10
- package/dist/memories/client.d.ts +91 -0
- package/dist/memories/client.js +0 -0
- package/dist/memories/context.d.ts +5 -0
- package/dist/memories/context.js +1 -0
- package/dist/memories/deep-recall/deep-recall.d.ts +67 -0
- package/dist/memories/deep-recall/deep-recall.js +1 -0
- package/dist/memories/deep-recall/index.d.ts +1 -0
- package/dist/memories/deep-recall/index.js +1 -0
- package/dist/memories/explore/explore.d.ts +18 -0
- package/dist/memories/explore/explore.js +7 -0
- package/dist/memories/explore/subgraph.d.ts +12 -0
- package/dist/memories/explore/subgraph.js +3 -0
- package/dist/memories/index.d.ts +30 -0
- package/dist/memories/index.js +1 -0
- package/dist/memories/observe/observe.d.ts +7 -0
- package/dist/memories/observe/observe.js +1 -0
- package/dist/memories/recall/recall.d.ts +42 -0
- package/dist/memories/recall/recall.js +1 -0
- package/dist/memories/recall/render.d.ts +5 -0
- package/dist/memories/recall/render.js +3 -0
- package/dist/memories/remember/remember.d.ts +49 -0
- package/dist/memories/remember/remember.js +1 -0
- package/dist/search/index.d.ts +5 -0
- package/dist/search/index.js +1 -0
- package/dist/search/retry.d.ts +6 -0
- package/dist/search/retry.js +1 -0
- package/dist/search/search.d.ts +63 -0
- package/dist/search/search.js +1 -0
- package/dist/search/searxng/index.d.ts +2 -0
- package/dist/search/searxng/index.js +1 -0
- package/dist/search/searxng/searxng.d.ts +14 -0
- package/dist/search/searxng/searxng.js +5 -0
- package/dist/search/searxng/text.d.ts +2 -0
- package/dist/search/searxng/text.js +3 -0
- package/dist/search/sonar/index.d.ts +2 -0
- package/dist/search/sonar/index.js +1 -0
- package/dist/search/sonar/reply.js +1 -0
- package/dist/search/sonar/sonar.d.ts +13 -0
- package/dist/search/sonar/sonar.js +1 -0
- package/dist/search/tavily/index.d.ts +4 -0
- package/dist/search/tavily/index.js +1 -0
- package/dist/search/tavily/reply.d.ts +22 -0
- package/dist/search/tavily/reply.js +5 -0
- package/dist/search/tavily/tavily.d.ts +14 -0
- package/dist/search/tavily/tavily.js +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/replay.d.ts +55 -0
- package/dist/testing/replay.js +1 -0
- package/dist/testing/store.d.ts +35 -0
- package/dist/testing/store.js +4 -0
- package/dist/tool/bind.js +1 -64
- package/dist/tool/index.js +1 -2
- package/dist/tool/tool.d.ts +5 -1
- package/dist/tool/tool.js +1 -51
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/result.d.ts +38 -0
- package/dist/types/result.js +0 -1
- package/dist/types/tool.d.ts +74 -0
- package/dist/types/tool.js +0 -1
- package/dist/wire/index.js +1 -3
- package/dist/wire/json-schema.js +1 -65
- package/dist/wire/markdown.d.ts +5 -3
- package/dist/wire/markdown.js +9 -29
- package/dist/wire/providers.js +1 -18
- package/docs/bogus.mdx +85 -0
- package/docs/builtins.mdx +156 -0
- package/docs/bus.mdx +90 -0
- package/docs/declaring.mdx +85 -0
- package/docs/effects.mdx +49 -0
- package/docs/overview.mdx +76 -0
- package/docs/selecting.mdx +79 -0
- package/docs/stored.mdx +83 -0
- package/docs/turns.mdx +57 -0
- package/package.json +28 -11
- package/dist/builtin/deep-recall/deep-recall.d.ts +0 -27
- package/dist/builtin/deep-recall/deep-recall.js +0 -45
- package/dist/builtin/deep-recall/index.d.ts +0 -1
- package/dist/builtin/deep-recall/index.js +0 -1
- package/dist/builtin/web-search/reply.js +0 -27
- package/dist/builtin/write-document/commission.js +0 -152
- package/dist/builtin/write-document/design.js +0 -86
- package/dist/builtin/write-document/http.js +0 -71
- package/dist/builtin/write-document/index.d.ts +0 -6
- package/dist/builtin/write-document/index.js +0 -4
- package/dist/builtin/write-document/types.js +0 -1
- package/dist/builtin/write-document/write-document.js +0 -142
- /package/dist/{builtin/write-document → agents/moa}/design.d.ts +0 -0
- /package/dist/{builtin/write-document → agents/moa}/http.d.ts +0 -0
- /package/dist/{builtin/web-search → search/sonar}/reply.d.ts +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
title: "Selecting tools — spendgraph docs",
|
|
3
|
+
description:
|
|
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
|
+
};
|
|
6
|
+
|
|
7
|
+
# Selecting
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const bus = toolbus([lookup, currentTime()], { limit: 6 });
|
|
11
|
+
|
|
12
|
+
bus.select(query) // the working set for one request
|
|
13
|
+
bus.anthropic(decls?) // declarations, per provider
|
|
14
|
+
bus.openai(decls?)
|
|
15
|
+
bus.markdown(decls?) // for a model with no tools API
|
|
16
|
+
await bus.invoke(name, args)
|
|
17
|
+
bus.missing(["refund"]) // names something expects that this bus lacks
|
|
18
|
+
bus.usage()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Holding tools in one place is what makes selection possible. Fifty tools at roughly 150 tokens of declaration each is **7.5k tokens on every call** before the user has said anything, so past a point you stop sending all of them.
|
|
22
|
+
|
|
23
|
+
## `limit` is an accuracy budget
|
|
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 — what is in front of the model is what costs accuracy, whoever put it there.
|
|
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** — the one outcome selection exists to avoid.
|
|
28
|
+
|
|
29
|
+
## Keyword and usage, deliberately
|
|
30
|
+
|
|
31
|
+
No embedding provider, no extra call, no latency. It is also the baseline that says what embeddings would buy.
|
|
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 — the case where an agent would otherwise be handed nothing.
|
|
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" — the same six for a Japanese question as for a Russian one.
|
|
36
|
+
|
|
37
|
+
## How a tool scores
|
|
38
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
45
|
+
## Aliases reach words a description never used
|
|
46
|
+
|
|
47
|
+
Lexical ranking cannot cross a synonym. A question about money back scores **zero** against a tool called `refund`, whatever its description says. Declare the wording callers actually type:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
tool({
|
|
51
|
+
name: "issue_refund",
|
|
52
|
+
aliases: ["money back", "chargeback", "reimburse"],
|
|
53
|
+
description: "Reverses a completed order and returns the payment.",
|
|
54
|
+
args,
|
|
55
|
+
run,
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
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
|
+
|
|
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 — an agent told to prefer `recall` and handed only `deep_recall` will reach for the one it does not have.
|
|
63
|
+
</Callout>
|
|
64
|
+
|
|
65
|
+
## Past a few hundred tools
|
|
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`](/docs/tools/bus) gives each domain its own bus and routes between them.
|
|
68
|
+
|
|
69
|
+
## `missing` catches a silent failure
|
|
70
|
+
|
|
71
|
+
A prompt edited to expect a `refund` tool, deployed against a bus without one, does not error. The agent is never offered it and answers anyway.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
bus.missing(["refund", "escalate"]); // the names this bus does not have
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Two tools that describe themselves the same way
|
|
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 — a line each buries whatever else the process said at startup and teaches people to switch the check off.
|
package/docs/stored.mdx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
title: "A stored tool — spendgraph docs",
|
|
3
|
+
description:
|
|
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
|
+
};
|
|
6
|
+
|
|
7
|
+
# A stored tool
|
|
8
|
+
|
|
9
|
+
The description is the biggest lever on whether a model calls a tool correctly, and the part you will want to change most often. `bindTool` puts that half in the dashboard and leaves the rest in code.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { bindTool } from "@spendgraph/tools";
|
|
13
|
+
import { Spendgraph } from "@spendgraph/sdk";
|
|
14
|
+
|
|
15
|
+
const lookup = await bindTool(sg, {
|
|
16
|
+
name: "lookup_contract",
|
|
17
|
+
args: [{ name: "ref", type: "string", required: true }] as const,
|
|
18
|
+
run: ({ ref }) => search(ref),
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
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
|
+
|
|
24
|
+
## Who owns what
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
DASHBOARD YOUR CODE
|
|
28
|
+
───────── ─────────
|
|
29
|
+
description ◀── iterate on args ──▶ run() is typed from them
|
|
30
|
+
effect these without run() ──▶ the thing that actually happens
|
|
31
|
+
pinned a deploy
|
|
32
|
+
```
|
|
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](/docs/tools/declaring) exists to provide.
|
|
35
|
+
|
|
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
|
+
|
|
38
|
+
## Drift fails at bind, not mid-loop
|
|
39
|
+
|
|
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
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { ToolDriftError, ToolNotStoredError } from "@spendgraph/tools";
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await bindTool(sg, spec);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err instanceof ToolDriftError) err.differences; // every disagreement, in words
|
|
49
|
+
if (err instanceof ToolNotStoredError) err.toolName; // nothing stored under that name
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`differences` reads like a review comment rather than a diff:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
"ref" is string here and number stored
|
|
57
|
+
"limit" is declared here but not stored
|
|
58
|
+
"mode" is stored but not declared here
|
|
59
|
+
```
|
|
60
|
+
|
|
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
|
+
|
|
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 — a bind inside a request handler puts a network hop in front of every model call.
|
|
65
|
+
</Callout>
|
|
66
|
+
|
|
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 — 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
|
+
</Callout>
|
|
70
|
+
|
|
71
|
+
## When nothing is stored yet
|
|
72
|
+
|
|
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
|
+
|
|
75
|
+
Create the row first — `sg.tools.create` on the [client](/docs/sdk/client), or the Tools page in the dashboard — then bind to it.
|
|
76
|
+
|
|
77
|
+
## It is an ordinary tool afterwards
|
|
78
|
+
|
|
79
|
+
What comes back is what `tool()` returns. Register it on a bus, select it, invoke it, record it; nothing downstream knows or cares where the wording came from.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const bus = toolbus([lookup, calculate()]);
|
|
83
|
+
```
|
package/docs/turns.mdx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
title: "A turn — spendgraph docs",
|
|
3
|
+
description:
|
|
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
|
+
};
|
|
6
|
+
|
|
7
|
+
# A turn
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const turn = bus.trace(query);
|
|
11
|
+
|
|
12
|
+
const reply = await llm.call(messages, { tools: turn });
|
|
13
|
+
await turn.invoke(call.name, call.args);
|
|
14
|
+
|
|
15
|
+
turn.record(); // { offeredTools, steps }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A turn is **selected once** and remembers what it offered. A trace cannot disagree with itself about what the model saw.
|
|
19
|
+
|
|
20
|
+
## Offered is recorded, not just called
|
|
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"** — which are opposite bugs, and look identical in a log that only lists calls.
|
|
23
|
+
|
|
24
|
+
## What a turn answers
|
|
25
|
+
|
|
26
|
+
| | |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `turn.offered` | the shortlist this turn was given |
|
|
29
|
+
| `turn.anthropic()` · `openai()` · `markdown()` | the same list, per provider |
|
|
30
|
+
| `turn.effectOf(name)` | what that tool said it does, or `undefined` |
|
|
31
|
+
| `turn.invoke(name, args)` | runs one and records the step. Never throws |
|
|
32
|
+
| `turn.steps()` | the steps so far, in call order |
|
|
33
|
+
| `turn.record()` | the pair, in the shape `report` takes |
|
|
34
|
+
|
|
35
|
+
## Never throwing is the point
|
|
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 — the same argument `status` and `error` exist on a rollout for.
|
|
38
|
+
|
|
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
|
+
|
|
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 — which is the difference between a model that declined to look something up and a model that could not.
|
|
43
|
+
</Callout>
|
|
44
|
+
|
|
45
|
+
## Usage drives the tiebreaker
|
|
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 — a name that does not exist, arguments that did not validate — is not counted, because it was not a use.
|
|
48
|
+
|
|
49
|
+
## With a prompt
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
await prompt.call(values, ({ messages, turn }) => llm.call(messages, { tools: turn }), {
|
|
53
|
+
tools: bus,
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The prompt hands the turn to your callback and puts `record()` on the rollout. The three packages compose without any of them importing another.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Declare a tool once, offer the right few, and record what was called.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/fnLog0/spendgraph.git",
|
|
@@ -28,10 +28,23 @@
|
|
|
28
28
|
"types": "./dist/index.d.ts",
|
|
29
29
|
"import": "./dist/index.js"
|
|
30
30
|
},
|
|
31
|
+
"./docs/*": "./docs/*",
|
|
32
|
+
"./bogus": {
|
|
33
|
+
"types": "./dist/bogus/index.d.ts",
|
|
34
|
+
"import": "./dist/bogus/index.js"
|
|
35
|
+
},
|
|
31
36
|
"./wire": {
|
|
32
37
|
"types": "./dist/wire/index.d.ts",
|
|
33
38
|
"import": "./dist/wire/index.js"
|
|
34
39
|
},
|
|
40
|
+
"./search": {
|
|
41
|
+
"types": "./dist/search/index.d.ts",
|
|
42
|
+
"import": "./dist/search/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./testing": {
|
|
45
|
+
"types": "./dist/testing/index.d.ts",
|
|
46
|
+
"import": "./dist/testing/index.js"
|
|
47
|
+
},
|
|
35
48
|
"./internals": {
|
|
36
49
|
"types": "./dist/internals.d.ts",
|
|
37
50
|
"import": "./dist/internals.js"
|
|
@@ -40,19 +53,18 @@
|
|
|
40
53
|
},
|
|
41
54
|
"files": [
|
|
42
55
|
"dist",
|
|
56
|
+
"docs",
|
|
43
57
|
"README.md"
|
|
44
58
|
],
|
|
45
|
-
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments",
|
|
47
|
-
"test": "vitest run",
|
|
48
|
-
"examples": "npm run build && node examples/02-select/01-shortlist.mjs"
|
|
49
|
-
},
|
|
50
59
|
"dependencies": {
|
|
51
|
-
"@
|
|
52
|
-
"
|
|
60
|
+
"@spendgraph/sdk": "^0.7.0",
|
|
61
|
+
"jszip": "^3.10.2",
|
|
62
|
+
"unpdf": "^1.8.1"
|
|
53
63
|
},
|
|
54
64
|
"devDependencies": {
|
|
55
|
-
"@
|
|
65
|
+
"@locusgraph/client": "^0.8.1",
|
|
66
|
+
"@spendgraph/prompt": "^0.7.0",
|
|
67
|
+
"pdf-lib": "^1.17.1",
|
|
56
68
|
"typescript": "^5"
|
|
57
69
|
},
|
|
58
70
|
"engines": {
|
|
@@ -60,5 +72,10 @@
|
|
|
60
72
|
},
|
|
61
73
|
"publishConfig": {
|
|
62
74
|
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
|
|
78
|
+
"test": "vitest run",
|
|
79
|
+
"examples": "pnpm run build && node examples/02-select/01-shortlist.mjs"
|
|
63
80
|
}
|
|
64
|
-
}
|
|
81
|
+
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type { LocusGraphClient } from "@locusgraph/client";
|
|
2
|
-
export interface DeepRecallOptions {
|
|
3
|
-
/** A `LocusGraphClient`, or anything carrying its `deepRecall`. */
|
|
4
|
-
client: Pick<LocusGraphClient, "deepRecall">;
|
|
5
|
-
/** Graph the walk runs over. Unset, the client's own default is used. */
|
|
6
|
-
graphId?: string;
|
|
7
|
-
/** Longest evidence list handed back. Default 20. */
|
|
8
|
-
maxFacts?: number;
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* Your own knowledge base, answered with cited evidence.
|
|
12
|
-
*
|
|
13
|
-
* Cost comes back as micro-USD because deep recall runs model calls of its own,
|
|
14
|
-
* and that spend is invisible to whatever loop called this.
|
|
15
|
-
*/
|
|
16
|
-
export declare function deepRecall(opts: DeepRecallOptions): import("../../index.js").Tool<import("../../index.js").ArgsOf<readonly [{
|
|
17
|
-
readonly name: "question";
|
|
18
|
-
readonly type: "string";
|
|
19
|
-
readonly required: true;
|
|
20
|
-
readonly maxLength: 1000;
|
|
21
|
-
readonly description: "One focused question, not a conversation and not several at once.";
|
|
22
|
-
}, {
|
|
23
|
-
readonly name: "context_ids";
|
|
24
|
-
readonly type: "list";
|
|
25
|
-
readonly required: false;
|
|
26
|
-
readonly description: string;
|
|
27
|
-
}]>>;
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { tool } from "../../tool/index.js";
|
|
2
|
-
const DEFAULT_MAX_FACTS = 20;
|
|
3
|
-
export function deepRecall(opts) {
|
|
4
|
-
const maxFacts = opts.maxFacts ?? DEFAULT_MAX_FACTS;
|
|
5
|
-
return tool({
|
|
6
|
-
name: "deep_recall",
|
|
7
|
-
description: "Searches your own knowledge base and returns an answer with the evidence it rests " +
|
|
8
|
-
"on. A walker follows linked contexts, so it answers questions spread across several " +
|
|
9
|
-
"notes — what do we know about X, what was decided and why. Ask one focused thing. " +
|
|
10
|
-
"Prefer it over a web search whenever the answer could already be in your records.",
|
|
11
|
-
effect: "readonly",
|
|
12
|
-
args: [
|
|
13
|
-
{
|
|
14
|
-
name: "question",
|
|
15
|
-
type: "string",
|
|
16
|
-
required: true,
|
|
17
|
-
maxLength: 1000,
|
|
18
|
-
description: "One focused question, not a conversation and not several at once.",
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
name: "context_ids",
|
|
22
|
-
type: "list",
|
|
23
|
-
required: false,
|
|
24
|
-
description: "Contexts to start the walk from, as type:name — take them from the contexts of " +
|
|
25
|
-
"an earlier result. A hint, not a filter; the walk may go elsewhere.",
|
|
26
|
-
},
|
|
27
|
-
],
|
|
28
|
-
run: async ({ question, context_ids }) => {
|
|
29
|
-
const result = await opts.client.deepRecall({
|
|
30
|
-
question,
|
|
31
|
-
...(opts.graphId ? { graphId: opts.graphId } : {}),
|
|
32
|
-
...(context_ids?.length ? { contextIds: context_ids } : {}),
|
|
33
|
-
});
|
|
34
|
-
return {
|
|
35
|
-
summary: result.summary,
|
|
36
|
-
facts: result.facts.slice(0, maxFacts),
|
|
37
|
-
truncated: result.facts.length > maxFacts,
|
|
38
|
-
contextsWalked: result.contexts_walked,
|
|
39
|
-
coverage: result.coverage,
|
|
40
|
-
fallback: result.fallback,
|
|
41
|
-
costMicroUsd: Math.round(result.usage.cost_usd * 1_000_000),
|
|
42
|
-
};
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { type DeepRecallOptions, deepRecall } from "./deep-recall.js";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { deepRecall } from "./deep-recall.js";
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
function sources(raw) {
|
|
2
|
-
const listed = raw.search_results ?? [];
|
|
3
|
-
if (listed.length) {
|
|
4
|
-
return listed.flatMap((r) => (r.url ? [{ url: r.url, title: r.title, date: r.date }] : []));
|
|
5
|
-
}
|
|
6
|
-
return (raw.citations ?? []).map((url) => ({ url }));
|
|
7
|
-
}
|
|
8
|
-
export function readReply(body, fallbackModel, maxChars, maxResults) {
|
|
9
|
-
const raw = body;
|
|
10
|
-
const answer = raw.choices?.[0]?.message?.content ?? "";
|
|
11
|
-
const found = sources(raw);
|
|
12
|
-
const results = found.slice(0, maxResults);
|
|
13
|
-
const usage = raw.usage ?? {};
|
|
14
|
-
return {
|
|
15
|
-
answer: answer.slice(0, maxChars),
|
|
16
|
-
results,
|
|
17
|
-
model: raw.model ?? fallbackModel,
|
|
18
|
-
truncated: answer.length > maxChars || found.length > results.length,
|
|
19
|
-
usage: {
|
|
20
|
-
inputTokens: usage.prompt_tokens ?? 0,
|
|
21
|
-
outputTokens: usage.completion_tokens ?? 0,
|
|
22
|
-
citationTokens: usage.citation_tokens ?? 0,
|
|
23
|
-
reasoningTokens: usage.reasoning_tokens ?? 0,
|
|
24
|
-
searchQueries: usage.num_search_queries ?? 0,
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { createRequest, MoaApiError } from "./http.js";
|
|
2
|
-
const TASK_STATUS = {
|
|
3
|
-
queued: "pending",
|
|
4
|
-
running: "running",
|
|
5
|
-
succeeded: "done",
|
|
6
|
-
failed: "failed",
|
|
7
|
-
canceled: "failed",
|
|
8
|
-
};
|
|
9
|
-
const ARTIFACT_FORMATS = ["md", "pdf", "docx", "pptx"];
|
|
10
|
-
const formatOf = (name) => {
|
|
11
|
-
const ext = name?.split(".").pop()?.toLowerCase();
|
|
12
|
-
return ext && ARTIFACT_FORMATS.includes(ext) ? ext : undefined;
|
|
13
|
-
};
|
|
14
|
-
function toArtifacts(raw) {
|
|
15
|
-
if (!raw?.length)
|
|
16
|
-
return undefined;
|
|
17
|
-
const artifacts = raw.flatMap((a) => {
|
|
18
|
-
const format = formatOf(a.name);
|
|
19
|
-
if (!format || !(a.url || a.data))
|
|
20
|
-
return [];
|
|
21
|
-
return [
|
|
22
|
-
{
|
|
23
|
-
name: a.name ?? `document.${format}`,
|
|
24
|
-
format,
|
|
25
|
-
url: a.url,
|
|
26
|
-
data: a.data,
|
|
27
|
-
mime: a.contentType,
|
|
28
|
-
sizeBytes: a.sizeBytes,
|
|
29
|
-
},
|
|
30
|
-
];
|
|
31
|
-
});
|
|
32
|
-
return artifacts.length ? artifacts : undefined;
|
|
33
|
-
}
|
|
34
|
-
function deliverablesBlock(formats) {
|
|
35
|
-
return (`\n\n<deliverables>\nBesides the text answer, save the finished deliverable ` +
|
|
36
|
-
`into the artifacts/ directory of your working dir as: ` +
|
|
37
|
-
`${formats.map((f) => `one .${f} file`).join(", ")}.\n</deliverables>`);
|
|
38
|
-
}
|
|
39
|
-
function taskText(input) {
|
|
40
|
-
let task = input.task;
|
|
41
|
-
if (input.context)
|
|
42
|
-
task += `\n\n<context>\n${input.context}\n</context>`;
|
|
43
|
-
if (input.design)
|
|
44
|
-
task += `\n\n<design>\n${input.design}\n</design>`;
|
|
45
|
-
if (input.artifacts?.length)
|
|
46
|
-
task += deliverablesBlock(input.artifacts);
|
|
47
|
-
return task;
|
|
48
|
-
}
|
|
49
|
-
export function createCommissionClient(config = {}) {
|
|
50
|
-
const request = createRequest(config);
|
|
51
|
-
return {
|
|
52
|
-
async create(input) {
|
|
53
|
-
const created = await request("/tasks", {
|
|
54
|
-
method: "POST",
|
|
55
|
-
body: {
|
|
56
|
-
task: taskText(input),
|
|
57
|
-
mode: "striker",
|
|
58
|
-
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
59
|
-
...(input.newSessionId ? { newSessionId: input.newSessionId } : {}),
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
if (!created.id)
|
|
63
|
-
throw new MoaApiError("Moa did not return a task id", 502);
|
|
64
|
-
return created.id;
|
|
65
|
-
},
|
|
66
|
-
async get(id, opts) {
|
|
67
|
-
const qs = opts?.waitMs && opts.waitMs > 0 ? `?waitMs=${Math.floor(opts.waitMs)}` : "";
|
|
68
|
-
const t = await request(`/tasks/${encodeURIComponent(id)}${qs}`, {
|
|
69
|
-
signal: opts?.signal,
|
|
70
|
-
});
|
|
71
|
-
const status = t.status ? TASK_STATUS[t.status] : undefined;
|
|
72
|
-
return {
|
|
73
|
-
id: t.id ?? id,
|
|
74
|
-
status: status ?? "running",
|
|
75
|
-
output: t.result,
|
|
76
|
-
error: t.error,
|
|
77
|
-
costUsd: t.costUsd,
|
|
78
|
-
numTurns: t.numTurns,
|
|
79
|
-
sessionId: t.sessionId,
|
|
80
|
-
artifacts: toArtifacts(t.artifacts),
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
async cancel(id) {
|
|
84
|
-
await request(`/tasks/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
export const INTERACTIVE_TIMEOUT_MS = 30_000;
|
|
89
|
-
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
90
|
-
const SETTLE_ATTEMPTS = 3;
|
|
91
|
-
const LONGPOLL_WINDOW_MS = 25_000;
|
|
92
|
-
export class CommissionAbortedError extends Error {
|
|
93
|
-
commissionId;
|
|
94
|
-
constructor(commissionId) {
|
|
95
|
-
super(`Commission ${commissionId} was cancelled by the caller.`);
|
|
96
|
-
this.name = "CommissionAbortedError";
|
|
97
|
-
this.commissionId = commissionId;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
export class CommissionTimeoutError extends Error {
|
|
101
|
-
commissionId;
|
|
102
|
-
timeoutMs;
|
|
103
|
-
constructor(commissionId, timeoutMs) {
|
|
104
|
-
super(`Commission ${commissionId} did not complete within ${timeoutMs / 1000}s. ` +
|
|
105
|
-
`The task may still be running in the background.`);
|
|
106
|
-
this.name = "CommissionTimeoutError";
|
|
107
|
-
this.commissionId = commissionId;
|
|
108
|
-
this.timeoutMs = timeoutMs;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
112
|
-
const onAbort = () => resolve();
|
|
113
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
114
|
-
setTimeout(() => {
|
|
115
|
-
signal?.removeEventListener("abort", onAbort);
|
|
116
|
-
resolve();
|
|
117
|
-
}, ms);
|
|
118
|
-
});
|
|
119
|
-
export async function pollUntilDone(client, id, opts = {}) {
|
|
120
|
-
const timeoutMs = opts.timeoutMs ?? INTERACTIVE_TIMEOUT_MS;
|
|
121
|
-
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
122
|
-
const settleMs = opts.settleMs ?? 0;
|
|
123
|
-
const signal = opts.signal;
|
|
124
|
-
const deadline = Date.now() + timeoutMs;
|
|
125
|
-
while (true) {
|
|
126
|
-
if (signal?.aborted)
|
|
127
|
-
throw new CommissionAbortedError(id);
|
|
128
|
-
const remaining = deadline - Date.now();
|
|
129
|
-
if (remaining <= 0)
|
|
130
|
-
throw new CommissionTimeoutError(id, timeoutMs);
|
|
131
|
-
let result;
|
|
132
|
-
try {
|
|
133
|
-
result = await client.get(id, { waitMs: Math.min(remaining, LONGPOLL_WINDOW_MS), signal });
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
if (signal?.aborted)
|
|
137
|
-
throw new CommissionAbortedError(id);
|
|
138
|
-
throw err;
|
|
139
|
-
}
|
|
140
|
-
if (result.status === "failed")
|
|
141
|
-
return result;
|
|
142
|
-
if (result.status === "done") {
|
|
143
|
-
const unsettled = (r) => !r.output?.trim() || (opts.expectArtifacts === true && !r.artifacts?.length);
|
|
144
|
-
for (let i = 0; settleMs > 0 && i < SETTLE_ATTEMPTS && unsettled(result); i++) {
|
|
145
|
-
await sleep(settleMs);
|
|
146
|
-
result = await client.get(id);
|
|
147
|
-
}
|
|
148
|
-
return result;
|
|
149
|
-
}
|
|
150
|
-
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), signal);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
export const DEFAULT_DESIGN = {
|
|
2
|
-
name: "Spendgraph house style",
|
|
3
|
-
fonts: { heading: "Inter", body: "Inter", mono: "JetBrains Mono" },
|
|
4
|
-
palette: {
|
|
5
|
-
primary: "#0F172A",
|
|
6
|
-
accent: "#2563EB",
|
|
7
|
-
text: "#111827",
|
|
8
|
-
muted: "#64748B",
|
|
9
|
-
surface: "#FFFFFF",
|
|
10
|
-
},
|
|
11
|
-
layout: "A4 portrait, 20mm margins, a single text column at 60-75 characters, a 4pt spacing scale, " +
|
|
12
|
-
"and one heading scale used consistently (28/20/16/12). Whitespace is the design.",
|
|
13
|
-
charts: "One message per figure, stated in its caption. Label series directly where there are three " +
|
|
14
|
-
"or fewer. No 3D, no gradients, no gridline heavier than the data, no pie chart past three " +
|
|
15
|
-
"slices, and axes that start at zero unless the caption says why not.",
|
|
16
|
-
tone: "Plain and specific. Lead with the finding, then the evidence. No filler adjectives, no " +
|
|
17
|
-
"throat-clearing, no sentence that survives being deleted.",
|
|
18
|
-
rules: [
|
|
19
|
-
"A cover carrying the title, date and author, and no other ornament.",
|
|
20
|
-
"Every number traceable to something in the source material; invent nothing.",
|
|
21
|
-
"Tables for anything being compared; prose for anything being argued.",
|
|
22
|
-
"No stock imagery, no clip art, no placeholder text in the finished file.",
|
|
23
|
-
"Page numbers on every page after the cover.",
|
|
24
|
-
],
|
|
25
|
-
};
|
|
26
|
-
const FORMAT_CRAFT = {
|
|
27
|
-
pdf: "Cover, then contents if it runs past four pages, then the body. Figures captioned and " +
|
|
28
|
-
"referenced from the text. Nothing straddles a page break: no orphan heading, no split table row.",
|
|
29
|
-
pptx: "16:9. One idea per slide, and the title is that idea as a sentence — a claim, not a label. " +
|
|
30
|
-
"At most six lines of body per slide, one figure per slide at most, and everything you cut " +
|
|
31
|
-
"goes into the speaker notes. Close on decisions and next steps, not on 'thank you'.",
|
|
32
|
-
docx: "Real Word styles for headings rather than manual bold, a table-of-contents field rather " +
|
|
33
|
-
"than a typed list, a header row on every table, and captions on figures.",
|
|
34
|
-
md: "A title heading, ATX headings throughout, tables for comparisons, fenced code with a language tag, and no HTML.",
|
|
35
|
-
};
|
|
36
|
-
function mergeGroup(base, over) {
|
|
37
|
-
if (!base)
|
|
38
|
-
return over;
|
|
39
|
-
if (!over)
|
|
40
|
-
return base;
|
|
41
|
-
return { ...base, ...over };
|
|
42
|
-
}
|
|
43
|
-
export function mergeDesign(base, over) {
|
|
44
|
-
if (!over)
|
|
45
|
-
return base;
|
|
46
|
-
return {
|
|
47
|
-
...base,
|
|
48
|
-
...over,
|
|
49
|
-
fonts: mergeGroup(base.fonts, over.fonts),
|
|
50
|
-
palette: mergeGroup(base.palette, over.palette),
|
|
51
|
-
rules: [...(base.rules ?? []), ...(over.rules ?? [])],
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
const line = (label, value) => (value ? [`${label}: ${value}`] : []);
|
|
55
|
-
export function renderDesign(guide, format, notes) {
|
|
56
|
-
const { fonts, palette } = guide;
|
|
57
|
-
const parts = [
|
|
58
|
-
...line("Style", guide.name),
|
|
59
|
-
...line("Typefaces", [
|
|
60
|
-
fonts?.heading && `headings ${fonts.heading}`,
|
|
61
|
-
fonts?.body && `body ${fonts.body}`,
|
|
62
|
-
fonts?.mono && `code ${fonts.mono}`,
|
|
63
|
-
]
|
|
64
|
-
.filter(Boolean)
|
|
65
|
-
.join(", ") || undefined),
|
|
66
|
-
...line("Palette", [
|
|
67
|
-
palette?.primary && `primary ${palette.primary}`,
|
|
68
|
-
palette?.accent && `accent ${palette.accent}`,
|
|
69
|
-
palette?.text && `text ${palette.text}`,
|
|
70
|
-
palette?.muted && `muted ${palette.muted}`,
|
|
71
|
-
palette?.surface && `surface ${palette.surface}`,
|
|
72
|
-
]
|
|
73
|
-
.filter(Boolean)
|
|
74
|
-
.join(", ") || undefined),
|
|
75
|
-
...line("Layout", guide.layout),
|
|
76
|
-
...line("Figures", guide.charts),
|
|
77
|
-
...line("Voice", guide.tone),
|
|
78
|
-
...line("Logo", guide.logoUrl),
|
|
79
|
-
`Format craft (.${format}): ${FORMAT_CRAFT[format]}`,
|
|
80
|
-
...(guide.rules?.length ? [`Rules:\n${guide.rules.map((r) => `- ${r}`).join("\n")}`] : []),
|
|
81
|
-
...(notes?.trim()
|
|
82
|
-
? [`Overrides for this document, which win over everything above:\n${notes.trim()}`]
|
|
83
|
-
: []),
|
|
84
|
-
];
|
|
85
|
-
return parts.join("\n");
|
|
86
|
-
}
|