@spendgraph/harness 0.1.0 → 0.2.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 (119) hide show
  1. package/README.md +87 -160
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +91 -0
  4. package/dist/cascade/index.d.ts +3 -0
  5. package/dist/cascade/index.js +2 -0
  6. package/dist/cascade/tier.d.ts +31 -0
  7. package/dist/cascade/tier.js +53 -0
  8. package/dist/cascade/types.d.ts +98 -0
  9. package/dist/chain/chain.d.ts +9 -0
  10. package/dist/chain/chain.js +58 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +33 -0
  13. package/dist/chain/index.d.ts +3 -0
  14. package/dist/chain/index.js +2 -0
  15. package/dist/chain/step.d.ts +9 -0
  16. package/dist/chain/step.js +14 -0
  17. package/dist/chain/types.d.ts +50 -0
  18. package/dist/index.d.ts +16 -9
  19. package/dist/index.js +8 -7
  20. package/dist/loop/act.d.ts +21 -0
  21. package/dist/loop/act.js +43 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +59 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +41 -0
  26. package/dist/loop/index.d.ts +6 -0
  27. package/dist/loop/index.js +5 -0
  28. package/dist/loop/loop.d.ts +13 -0
  29. package/dist/loop/loop.js +120 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +17 -0
  32. package/dist/loop/types.d.ts +140 -0
  33. package/dist/loop/types.js +1 -0
  34. package/dist/orchestrate/index.d.ts +4 -0
  35. package/dist/orchestrate/index.js +3 -0
  36. package/dist/orchestrate/orchestrate.d.ts +12 -0
  37. package/dist/orchestrate/orchestrate.js +68 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +38 -0
  40. package/dist/orchestrate/types.d.ts +75 -0
  41. package/dist/orchestrate/types.js +1 -0
  42. package/dist/orchestrate/work.d.ts +13 -0
  43. package/dist/orchestrate/work.js +35 -0
  44. package/dist/parallel/index.d.ts +4 -0
  45. package/dist/parallel/index.js +3 -0
  46. package/dist/parallel/merge.d.ts +26 -0
  47. package/dist/parallel/merge.js +52 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +66 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +45 -0
  52. package/dist/parallel/types.d.ts +61 -0
  53. package/dist/parallel/types.js +1 -0
  54. package/dist/refine/attempt.d.ts +22 -0
  55. package/dist/refine/attempt.js +38 -0
  56. package/dist/refine/index.d.ts +4 -0
  57. package/dist/refine/index.js +3 -0
  58. package/dist/refine/judge.d.ts +15 -0
  59. package/dist/refine/judge.js +25 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +67 -0
  62. package/dist/refine/types.d.ts +90 -0
  63. package/dist/refine/types.js +1 -0
  64. package/dist/route/classify.d.ts +52 -0
  65. package/dist/route/classify.js +57 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +33 -0
  68. package/dist/route/index.d.ts +3 -0
  69. package/dist/route/index.js +2 -0
  70. package/dist/route/route.d.ts +11 -0
  71. package/dist/route/route.js +31 -0
  72. package/dist/route/types.d.ts +53 -0
  73. package/dist/route/types.js +1 -0
  74. package/dist/stream/graph.d.ts +9 -0
  75. package/dist/stream/graph.js +16 -0
  76. package/dist/stream/index.d.ts +2 -0
  77. package/dist/stream/index.js +1 -0
  78. package/dist/stream/stream.d.ts +19 -0
  79. package/dist/stream/stream.js +65 -0
  80. package/dist/stream/types.d.ts +31 -0
  81. package/dist/stream/types.js +1 -0
  82. package/docs/README.md +41 -0
  83. package/docs/cascade.md +165 -0
  84. package/docs/chain.md +134 -0
  85. package/docs/loop.md +318 -0
  86. package/docs/orchestrate.md +128 -0
  87. package/docs/parallel.md +139 -0
  88. package/docs/refine.md +206 -0
  89. package/docs/route.md +212 -0
  90. package/package.json +18 -14
  91. package/bin/harness.mjs +0 -76
  92. package/dist/core/budget.d.ts +0 -46
  93. package/dist/core/budget.js +0 -72
  94. package/dist/core/cache.d.ts +0 -119
  95. package/dist/core/cache.js +0 -206
  96. package/dist/core/client.d.ts +0 -33
  97. package/dist/core/client.js +0 -98
  98. package/dist/core/errors.d.ts +0 -22
  99. package/dist/core/errors.js +0 -28
  100. package/dist/core/index.d.ts +0 -5
  101. package/dist/core/index.js +0 -5
  102. package/dist/core/pull.d.ts +0 -17
  103. package/dist/core/pull.js +0 -38
  104. package/dist/core/schema/serialize.d.ts +0 -12
  105. package/dist/core/schema/serialize.js +0 -42
  106. package/dist/core/schema/types.d.ts +0 -58
  107. package/dist/core/schema/validate.d.ts +0 -28
  108. package/dist/core/schema/validate.js +0 -98
  109. package/dist/harness.d.ts +0 -128
  110. package/dist/harness.js +0 -187
  111. package/dist/prompt/codegen.d.ts +0 -37
  112. package/dist/prompt/codegen.js +0 -97
  113. package/dist/prompt/render.d.ts +0 -35
  114. package/dist/prompt/render.js +0 -66
  115. package/dist/prompt/run.d.ts +0 -38
  116. package/dist/prompt/run.js +0 -96
  117. package/dist/prompt/types.d.ts +0 -156
  118. /package/dist/{core/schema → cascade}/types.js +0 -0
  119. /package/dist/{prompt → chain}/types.js +0 -0
package/docs/refine.md ADDED
@@ -0,0 +1,206 @@
1
+ # refine
2
+
3
+ Draft, judge, revise. Stop when it is good enough, or when the budget is out.
4
+
5
+ ```ts
6
+ import { refine } from "@spendgraph/harness";
7
+
8
+ const result = await refine({
9
+ rounds: 3,
10
+ maxTokens: 20_000,
11
+ attempt: (feedback) =>
12
+ llm.call([{ role: "user", content: feedback ? `${task}\n\nFix this: ${feedback}` : task }]),
13
+ judge: (attempt) =>
14
+ attempt.output.length <= 160
15
+ ? { accepted: true }
16
+ : { accepted: false, feedback: `That is ${attempt.output.length} characters. The limit is 160.` },
17
+ });
18
+
19
+ result.output; // the winning draft
20
+ result.accepted; // false when the rounds or the tokens ran out
21
+ result.stoppedBy; // accepted · rounds · tokens · failed
22
+ result.steps; // every attempt and every judgement
23
+ ```
24
+
25
+ One `attempt` callback rather than separate generate and revise, because in
26
+ practice they are the same prompt with one extra paragraph — `feedback` is null
27
+ on the first round and the judge's note after.
28
+
29
+ ---
30
+
31
+ ## Example: an invoice that has to add up
32
+
33
+ The judge is plain code, which is the case worth reaching for. It costs nothing,
34
+ it is deterministic, and it cannot be talked into agreeing.
35
+
36
+ ```ts
37
+ const result = await refine({
38
+ rounds: 3,
39
+
40
+ attempt: (feedback) =>
41
+ llm.call([{ role: "user", content: `${email}${feedback ?? ""}` }], { schema: INVOICE }),
42
+
43
+ judge: (attempt) => {
44
+ const invoice = JSON.parse(attempt.output);
45
+ const summed = invoice.lines.reduce((n, l) => n + l.amount, 0);
46
+
47
+ return summed === invoice.total
48
+ ? { accepted: true }
49
+ : {
50
+ accepted: false,
51
+ feedback: `\n\nThe lines add up to ${summed} but the total says ${invoice.total}. A line is missing.`,
52
+ };
53
+ },
54
+ });
55
+ ```
56
+
57
+ What happens:
58
+
59
+ ```
60
+ round 0 → { lines: [1200, 50], total: 1550 } ✗ lines sum to 1250
61
+ feedback: "The lines add up to 1250 but the total says 1550…"
62
+ round 1 → { lines: [1200, 50, 300], total: 1550 } ✓ accepted
63
+
64
+ accepted: true rounds: 2 steps: 4
65
+ ```
66
+
67
+ The model dropped the shipping line. Told exactly what was inconsistent, it
68
+ found it. A **retry** would have run the same prompt and often made the same
69
+ mistake; the feedback is the difference.
70
+
71
+ A model judge asked "is this extracted correctly?" would have said yes, because
72
+ the JSON looks fine. Arithmetic does not have that problem.
73
+
74
+ ---
75
+
76
+ ## More judges that cost nothing
77
+
78
+ **SQL that has to run.** `EXPLAIN` is free and the database's own error is the
79
+ best feedback there is.
80
+
81
+ ```ts
82
+ judge: async (attempt) => {
83
+ try {
84
+ await db.prepare(`EXPLAIN ${attempt.output}`).run();
85
+ return { accepted: true };
86
+ } catch (err) {
87
+ return { accepted: false, feedback: String(err.message) };
88
+ }
89
+ }
90
+ ```
91
+
92
+ Hand the error back verbatim. Rewriting it into something friendlier throws away
93
+ the column name that tells the model what to fix.
94
+
95
+ **Code that has to pass its tests.** The strongest judge there is, because it is
96
+ the same bar a person would apply. Slow, so keep `rounds` low — three failed
97
+ attempts at a minute each is three minutes before you learn the prompt is wrong.
98
+
99
+ ```ts
100
+ judge: async (attempt) => {
101
+ const { failed, output } = await runTests(attempt.output);
102
+ return failed === 0 ? { accepted: true } : { accepted: false, feedback: output };
103
+ }
104
+ ```
105
+
106
+ **Placeholders that must survive a translation.** Models translate `{count}`
107
+ about one time in fifty, and it fails at runtime for whoever speaks that
108
+ language.
109
+
110
+ ```ts
111
+ judge: (attempt) => {
112
+ const wanted = source.match(/\{[a-z_]+\}/g) ?? [];
113
+ const missing = wanted.filter((token) => !attempt.output.includes(token));
114
+ return missing.length === 0
115
+ ? { accepted: true }
116
+ : { accepted: false, feedback: `Keep these exactly as they are: ${missing.join(", ")}.` };
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## When the judge has to be a model
123
+
124
+ Only when no code can decide — tone, policy, "would a person find this rude".
125
+ It is a second model call every round, so the loop is now at least twice the
126
+ price of the answer.
127
+
128
+ ```ts
129
+ judge: async (attempt) => {
130
+ const verdict = await judgeLlm.call([{ role: "user", content: `${RUBRIC}\n\n${attempt.output}` }], {
131
+ schema: {
132
+ type: "object",
133
+ properties: { score: { type: "number" }, fix: { type: "string" } },
134
+ required: ["score"],
135
+ },
136
+ });
137
+
138
+ const { score, fix } = verdict.data;
139
+ return score >= 8 ? { accepted: true, score } : { accepted: false, score, feedback: fix };
140
+ }
141
+ ```
142
+
143
+ Always return `score`, even on acceptance. Without it, a run that never passes
144
+ hands back the **last** attempt rather than the best one.
145
+
146
+ ---
147
+
148
+ ## When not to use it
149
+
150
+ **You cannot state the bar.** If the judge accepts everything you have tripled
151
+ the cost to learn nothing. Write the check first; if you cannot, the pattern does
152
+ not apply.
153
+
154
+ **Round one already passes almost always.** At 99% you are paying a judge on
155
+ every request to catch the hundredth. Retry-on-failure is cheaper than a loop by
156
+ default.
157
+
158
+ **The failure is not fixable by trying again.** A missing document, a permission
159
+ the agent does not have, a question with no answer — feedback cannot help, and
160
+ three rounds is three ways of saying so. Escalate instead.
161
+
162
+ ---
163
+
164
+ ## Reading the result
165
+
166
+ `stoppedBy` is the field to watch in production.
167
+
168
+ | | What it means |
169
+ | --- | --- |
170
+ | `accepted` | working as intended |
171
+ | `rounds` | the judge is too strict, or the prompt cannot get there |
172
+ | `tokens` | the ceiling is too low, or the attempts are too big |
173
+ | `failed` | the provider failed; not a quality problem |
174
+
175
+ A rising share of `rounds` is the signal to fix the prompt rather than raise the
176
+ limit. `history` carries every attempt with its verdict and token count, so "does
177
+ refining pay on this task?" is a query — round-one acceptance rate against the
178
+ cost of the extra calls — rather than a hunch.
179
+
180
+ **A failed attempt ends the run** rather than being judged. Asking a judge what
181
+ it thinks of an error spends a call to be told it is bad.
182
+
183
+ **The ceiling is in tokens, not money**, because this package cannot price a
184
+ call — that happens on the server, after the fact. It is checked *before* each
185
+ round, so it is never crossed rather than merely noticed once it has been.
186
+
187
+ ---
188
+
189
+ ## How the files fit
190
+
191
+ ```
192
+ types.ts the contract
193
+
194
+ attempt.ts what a draft cost, and how it is recorded
195
+ judge.ts how a verdict is recorded, and which draft wins
196
+
197
+ refine.ts the loop: draft · judge · stop
198
+ ```
199
+
200
+ `attempt.ts` and `judge.ts` are siblings — neither knows about the other, and
201
+ both import only the contract. The loop is the only file that knows the order
202
+ they run in.
203
+
204
+ `best` lives with the judge rather than the loop because the choice is the
205
+ judge's: it is made on the verdicts, and a judge that never scores gets the
206
+ fallback to the last attempt.
package/docs/route.md ADDED
@@ -0,0 +1,212 @@
1
+ # route
2
+
3
+ Classify once, then hand the work to the branch built for it.
4
+
5
+ ```ts
6
+ import { route } from "@spendgraph/harness";
7
+
8
+ const result = await route(
9
+ { subject, body },
10
+ {
11
+ classify: ({ subject }) => (/refund|invoice/i.test(subject) ? "billing" : "technical"),
12
+ routes: {
13
+ billing: (input) => haiku.bind(billingPrompt).invoke(input),
14
+ technical: (input) => opus.bind(docsPrompt).invoke(input),
15
+ },
16
+ fallback: (input) => opus.bind(generalPrompt).invoke(input),
17
+ }
18
+ );
19
+
20
+ result.route; // "billing", or null when the fallback ran
21
+ result.classified; // what the classifier said, used or not
22
+ result.inputTokens; // the classifier's tokens plus the branch's
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Example: support triage, routed for cost
28
+
29
+ Most tickets are simple. Only a few need the expensive model.
30
+
31
+ ```ts
32
+ const result = await route(
33
+ { subject, body },
34
+ {
35
+ classify: ({ subject, body }) => {
36
+ const text = `${subject} ${body}`.toLowerCase();
37
+ if (/refund|charged|invoice/.test(text)) return "billing";
38
+ if (/error|crash|not working/.test(text)) return "technical";
39
+ return "general";
40
+ },
41
+
42
+ routes: {
43
+ billing: (input) => haiku.bind(billingPrompt).invoke(input),
44
+ technical: (input) => opus.bind(docsPrompt).invoke(input),
45
+ general: () => ({ output: "Thanks — a human will reply within a day.", model: "none" }),
46
+ },
47
+
48
+ fallback: (input) => opus.bind(generalPrompt).invoke(input),
49
+ }
50
+ );
51
+ ```
52
+
53
+ ```
54
+ result.route "billing"
55
+ result.output "Your 12 March charge was a duplicate; it's been refunded."
56
+ result.inputTokens 340 classifier (0) + branch
57
+ result.steps [ classify, billing ]
58
+ ```
59
+
60
+ Three things this is doing:
61
+
62
+ The **classifier is a regex** — free, instant, and it cannot invent a category.
63
+ That is the common case.
64
+
65
+ The **`general` branch calls no model at all.** It returns a canned line at zero
66
+ cost. If a third of your tickets land there, that third is now free, which no
67
+ amount of prompt tuning gets you.
68
+
69
+ **`billing` uses the small model, `technical` the big one.** That is the whole
70
+ cost argument: you stop paying opus rates for "where is my invoice".
71
+
72
+ ---
73
+
74
+ ## Example: a classifier that is a model
75
+
76
+ When no regex will ever be right — sentiment, intent, "is this actually a
77
+ complaint" — use a model, and constrain it with an enum so it can only answer
78
+ with a route you have.
79
+
80
+ ```ts
81
+ classify: async ({ subject, body }) => {
82
+ const reply = await haiku.call(
83
+ [{ role: "user", content: `Classify this ticket.\n\n${subject}\n${body}` }],
84
+ {
85
+ schema: {
86
+ type: "object",
87
+ properties: {
88
+ route: { type: "string", enum: ["billing", "technical", "general"] },
89
+ confidence: { type: "number" },
90
+ reason: { type: "string" },
91
+ },
92
+ required: ["route", "confidence"],
93
+ },
94
+ }
95
+ );
96
+
97
+ return {
98
+ ...reply.data,
99
+ model: reply.model,
100
+ inputTokens: reply.inputTokens,
101
+ outputTokens: reply.outputTokens,
102
+ };
103
+ },
104
+ minConfidence: 0.7,
105
+ ```
106
+
107
+ The `enum` is doing real work. A free-text classifier eventually answers
108
+ `"Billing"` or `"billing question"` or a whole sentence, and every one of those
109
+ falls through to the fallback silently. Constrained, it can only say one of
110
+ three things — and on Anthropic that is a forced tool call, which is a guarantee
111
+ rather than a request.
112
+
113
+ Returning `model` and the token counts is what keeps the cost honest: a
114
+ classifier running on every request is not free, and without them
115
+ `result.inputTokens` reports only the branch.
116
+
117
+ **Self-reported `confidence` is weak.** Models are overconfident and the number
118
+ is not calibrated. `minConfidence` is a blunt safety net for the genuinely
119
+ ambiguous cases, not a dial to tune to three decimal places.
120
+
121
+ **Check the arithmetic before reaching for it.** A haiku classify might be 300
122
+ in, 20 out. Routing 80% of traffic from opus to haiku pays for itself many times
123
+ over. If every branch is a cheap model anyway, you have added a call to every
124
+ request to save nothing.
125
+
126
+ ---
127
+
128
+ ## What else it is for
129
+
130
+ **Capability.** A refund branch gets a bus with `escalate` pinned and a payments
131
+ tool; a docs branch gets retrieval and no write access. You cannot express that
132
+ in one call, because the tools are chosen before you know what was asked.
133
+
134
+ **Quality.** One prompt covering billing, technical and legal has to carry
135
+ instructions for all three, and every instruction is a chance to apply the wrong
136
+ one. Three prompts each do one thing.
137
+
138
+ **Safety.** Classify first; if it is out of scope or harmful, route to a branch
139
+ that declines and hands off. More reliable than hoping one prompt's guardrails
140
+ hold, because the decision is a separate call you can test.
141
+
142
+ Beyond support, the same shape: document type → invoice, contract or receipt,
143
+ each with its own extraction schema. Language detection → a localised prompt.
144
+ Complexity → a template for the trivial, an agent loop for the rest.
145
+
146
+ ---
147
+
148
+ ## When not to use it
149
+
150
+ **The branches would share 90% of their prompt.** You have split one thing into
151
+ three that now drift apart.
152
+
153
+ **The classification is genuinely hard.** You have moved the failure earlier
154
+ without removing it — a confidently wrong route sends the question to a
155
+ specialist with no idea what to do with it. `minConfidence` and a required
156
+ `fallback` exist for exactly this, but they are a net, not a fix.
157
+
158
+ ---
159
+
160
+ ## Reading the result
161
+
162
+ **Watch `route === null`.** A rising share of fallbacks means either the
163
+ classifier is drifting or there is a kind of request you never built a branch
164
+ for — and since `classified` records what the classifier *said* separately from
165
+ what ran, you can tell which.
166
+
167
+ ```
168
+ classifier says "legal", no such branch → classified "legal" route null
169
+ classifier says "billing" at 0.4, bar 0.7 → classified "billing" route null
170
+ ```
171
+
172
+ Same `route`, different `classified`: the first is a **missing branch**, the
173
+ second a **miscalibrated** one, and they want opposite fixes.
174
+
175
+ **`fallback` is required.** A router that can fail to route fails in production
176
+ at 3am, and "unknown" is a class every classifier eventually returns.
177
+
178
+ `route` and `refine` compose: a branch can be a refine loop, and its rounds
179
+ appear as `billing.attempt_0`, `billing.judge_0` rather than being flattened
180
+ into one step.
181
+
182
+ ---
183
+
184
+ ## How the files fit
185
+
186
+ ```
187
+ types.ts the contract
188
+
189
+ classify.ts what the classifier said, and what runs because of it
190
+
191
+ dispatch.ts where the work goes: a node per branch, and the edges to them
192
+
193
+ route.ts compile · run · shape the result
194
+ ```
195
+
196
+ Each file imports only downwards, which is why `decide` can be tested on its own
197
+ with no graph in sight.
198
+
199
+ It compiles to a graph rather than looping here. `@spendgraph/graph` already
200
+ validates that every branch is reachable and that no default sits in front of
201
+ one, already records a step per node with its tokens, and already returns the
202
+ rollout shape. A second execution engine in this package would be a second thing
203
+ to keep right.
204
+
205
+ **The decision travels in the graph, not in a closure.** The classifier
206
+ *returns* its decision, so it lands in `outputs.classify` like any node's return
207
+ and the edges read it from the context they are already handed. A finished run
208
+ can be read apart from `outputs.classify` alone.
209
+
210
+ **The edge order matters.** Conditional edges first, the unconditional fallback
211
+ last — `graph` refuses the other order, and is right to: an unconditional edge
212
+ ahead of a conditional one means the branch behind it can never be reached.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spendgraph/harness",
3
- "version": "0.1.0",
4
- "description": "Pull a stored prompt, render it, report what it cost. Zero dependencies.",
3
+ "version": "0.2.0",
4
+ "description": "Workflows: the shapes an LLM app takes, built on prompts, tools and graphs.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -10,10 +10,11 @@
10
10
  },
11
11
  "keywords": [
12
12
  "llm",
13
- "prompt",
14
- "prompt-management",
15
- "cost",
16
- "evals"
13
+ "agent",
14
+ "workflow",
15
+ "orchestration",
16
+ "routing",
17
+ "evaluator"
17
18
  ],
18
19
  "type": "module",
19
20
  "main": "./dist/index.js",
@@ -23,22 +24,22 @@
23
24
  "types": "./dist/index.d.ts",
24
25
  "import": "./dist/index.js"
25
26
  },
26
- "./core": {
27
- "types": "./dist/core/index.d.ts",
28
- "import": "./dist/core/index.js"
29
- },
30
27
  "./package.json": "./package.json"
31
28
  },
32
29
  "files": [
33
- "bin",
34
30
  "dist",
31
+ "docs",
35
32
  "README.md"
36
33
  ],
37
34
  "scripts": {
38
35
  "build": "tsc -p tsconfig.json",
39
- "test": "npm run build && vitest run"
36
+ "prebuild": "npm run build --workspace @spendgraph/graph --workspace @spendgraph/tools --workspace @spendgraph/prompt",
37
+ "test": "vitest run",
38
+ "pretest": "npm run build --workspace @spendgraph/graph --workspace @spendgraph/prompt --workspace @spendgraph/tools --workspace @spendgraph/llms",
39
+ "examples": "npm run build && node examples/run-all.mjs"
40
40
  },
41
41
  "devDependencies": {
42
+ "@spendgraph/llms": "^0.2.0",
42
43
  "typescript": "^5"
43
44
  },
44
45
  "engines": {
@@ -47,7 +48,10 @@
47
48
  "publishConfig": {
48
49
  "access": "public"
49
50
  },
50
- "bin": {
51
- "harness": "bin/harness.mjs"
51
+ "dependencies": {
52
+ "@spendgraph/graph": "^0.2.0",
53
+ "@spendgraph/prompt": "^0.2.0",
54
+ "@spendgraph/sdk": "^0.2.0",
55
+ "@spendgraph/tools": "^0.2.0"
52
56
  }
53
57
  }
package/bin/harness.mjs DELETED
@@ -1,76 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * harness codegen — writes a .d.ts describing every stored prompt's fields.
4
- *
5
- * SPENDGRAPH_API_KEY=sg_… SPENDGRAPH_URL=https://costs.example.com \
6
- * npx harness codegen --out src/harness.generated.d.ts
7
- *
8
- * Reads through the paginated list endpoint, so a project with hundreds of
9
- * prompts is many small requests rather than one enormous response.
10
- */
11
- import { writeFile, mkdir } from "node:fs/promises";
12
- import { dirname, resolve } from "node:path";
13
- import { emitPromptTypes } from "../dist/index.js";
14
-
15
- const args = process.argv.slice(2);
16
- const command = args[0];
17
-
18
- function flag(name, fallback) {
19
- const i = args.indexOf(`--${name}`);
20
- return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
21
- }
22
-
23
- function fail(message) {
24
- console.error(`harness: ${message}`);
25
- process.exit(1);
26
- }
27
-
28
- if (command !== "codegen") {
29
- console.log("usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]");
30
- process.exit(command ? 1 : 0);
31
- }
32
-
33
- const apiKey = process.env.SPENDGRAPH_API_KEY;
34
- const baseUrl = (process.env.SPENDGRAPH_URL ?? flag("url", "")).replace(/\/+$/, "");
35
- if (!apiKey) fail("set SPENDGRAPH_API_KEY");
36
- if (!baseUrl) fail("set SPENDGRAPH_URL, or pass --url");
37
-
38
- const out = resolve(flag("out", "src/harness.generated.d.ts"));
39
- const project = flag("project", "");
40
- const moduleName = flag("module", "@spendgraph/harness");
41
-
42
- const prompts = [];
43
- let cursor = null;
44
-
45
- // Bounded: a server that kept handing back a cursor must not spin forever.
46
- for (let page = 0; page < 200; page++) {
47
- // Archived prompts included on purpose. The dashboard hides them, but a
48
- // generated type disappearing because somebody tidied a list would break the
49
- // build of code that still pulls the prompt — archiving does not stop it
50
- // serving, so it must not stop it type-checking either.
51
- const params = new URLSearchParams({ limit: "200", archived: "all" });
52
- if (project) params.set("project", project);
53
- if (cursor) params.set("cursor", cursor);
54
-
55
- const res = await fetch(`${baseUrl}/api/v1/prompts?${params}`, {
56
- headers: { "x-api-key": apiKey },
57
- });
58
- if (!res.ok) {
59
- const body = await res.text().catch(() => "");
60
- fail(`${res.status} listing prompts — ${body.slice(0, 200)}`);
61
- }
62
-
63
- const body = await res.json();
64
- prompts.push(...(body.prompts ?? []));
65
- cursor = body.nextCursor;
66
- if (!cursor) break;
67
- }
68
-
69
- await mkdir(dirname(out), { recursive: true });
70
- await writeFile(out, emitPromptTypes(prompts, { module: moduleName }));
71
-
72
- const named = prompts.filter((p) => (p.fieldSpec ?? []).some((f) => f.required)).length;
73
- console.log(
74
- `harness: wrote ${prompts.length} prompt${prompts.length === 1 ? "" : "s"} to ${out}` +
75
- (named ? ` (${named} with required fields)` : "")
76
- );
@@ -1,46 +0,0 @@
1
- /**
2
- * Thrown when a client has spent its ceiling.
3
- *
4
- * Deliberately not a `HarnessError`: nothing was refused by the server, so a
5
- * caller inspecting `status` or `retryable` would be told a story about HTTP
6
- * that never happened.
7
- */
8
- export declare class BudgetExceededError extends Error {
9
- readonly spentMicros: number;
10
- readonly limitMicros: number;
11
- constructor(spentMicros: number, limitMicros: number);
12
- }
13
- /**
14
- * What this client has spent, and whether it may spend more.
15
- *
16
- * A loop that samples k rollouts across a dataset is the shape of an expensive
17
- * accident: one bad `k`, one dataset larger than expected, and the bill arrives
18
- * before anyone notices. The guard is a counter and a comparison, and a cost
19
- * meter that shipped without one would be embarrassing.
20
- *
21
- * Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
22
- * not gate `report`, which records money the caller already spent elsewhere;
23
- * counting that would make the ceiling mean two different things at once.
24
- */
25
- export declare class Budget {
26
- private used;
27
- private readonly limit;
28
- /**
29
- * `undefined` means no ceiling and warns once — an unbounded client in a
30
- * scheduled script is how a surprise bill happens. `null` means the same
31
- * thing deliberately, and says nothing.
32
- */
33
- constructor(maxCostMicros?: number | null);
34
- spent(): number;
35
- remaining(): number;
36
- add(costMicros: number): void;
37
- /**
38
- * Throws if the ceiling is already reached.
39
- *
40
- * Checked before a request rather than after: stopping once the bill has been
41
- * incurred is a report, not a limit.
42
- */
43
- assertAffordable(): void;
44
- }
45
- /** Test seam: lets the once-only warning be exercised more than once. */
46
- export declare function resetBudgetWarning(): void;
@@ -1,72 +0,0 @@
1
- /**
2
- * Thrown when a client has spent its ceiling.
3
- *
4
- * Deliberately not a `HarnessError`: nothing was refused by the server, so a
5
- * caller inspecting `status` or `retryable` would be told a story about HTTP
6
- * that never happened.
7
- */
8
- export class BudgetExceededError extends Error {
9
- spentMicros;
10
- limitMicros;
11
- constructor(spentMicros, limitMicros) {
12
- super(`Cost ceiling reached: spent $${(spentMicros / 1e6).toFixed(4)} of ` +
13
- `$${(limitMicros / 1e6).toFixed(4)}. Raise maxCostMicros or start a new client.`);
14
- this.name = "BudgetExceededError";
15
- this.spentMicros = spentMicros;
16
- this.limitMicros = limitMicros;
17
- }
18
- }
19
- let warned = false;
20
- /**
21
- * What this client has spent, and whether it may spend more.
22
- *
23
- * A loop that samples k rollouts across a dataset is the shape of an expensive
24
- * accident: one bad `k`, one dataset larger than expected, and the bill arrives
25
- * before anyone notices. The guard is a counter and a comparison, and a cost
26
- * meter that shipped without one would be embarrassing.
27
- *
28
- * Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
29
- * not gate `report`, which records money the caller already spent elsewhere;
30
- * counting that would make the ceiling mean two different things at once.
31
- */
32
- export class Budget {
33
- used = 0;
34
- limit;
35
- /**
36
- * `undefined` means no ceiling and warns once — an unbounded client in a
37
- * scheduled script is how a surprise bill happens. `null` means the same
38
- * thing deliberately, and says nothing.
39
- */
40
- constructor(maxCostMicros) {
41
- this.limit = maxCostMicros ?? Infinity;
42
- if (maxCostMicros === undefined && !warned) {
43
- warned = true;
44
- console.warn("[harness] No maxCostMicros set — this client can spend without limit. " +
45
- "Pass maxCostMicros, or null to say you meant it.");
46
- }
47
- }
48
- spent() {
49
- return this.used;
50
- }
51
- remaining() {
52
- return this.limit === Infinity ? Infinity : Math.max(0, this.limit - this.used);
53
- }
54
- add(costMicros) {
55
- if (Number.isFinite(costMicros) && costMicros > 0)
56
- this.used += costMicros;
57
- }
58
- /**
59
- * Throws if the ceiling is already reached.
60
- *
61
- * Checked before a request rather than after: stopping once the bill has been
62
- * incurred is a report, not a limit.
63
- */
64
- assertAffordable() {
65
- if (this.used >= this.limit)
66
- throw new BudgetExceededError(this.used, this.limit);
67
- }
68
- }
69
- /** Test seam: lets the once-only warning be exercised more than once. */
70
- export function resetBudgetWarning() {
71
- warned = false;
72
- }