@capagents/forgeqe 1.2.1 → 1.3.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/HOW_TO_USE.md +39 -2
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +53 -6
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +35 -4
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +59 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +155 -3
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/loop.ts +17 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +22 -2
- package/src/suite/run.ts +12 -5
- package/src/summary.ts +1 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +89 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/types.ts +9 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { BudgetConfig } from "../tokenomics/budget.ts"
|
|
5
|
+
import { readLedger, sessionCalls, type CallRecord } from "../tokenomics/ledger.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a report says about model usage: the report's own calls, one by one, and the last days of the ledger
|
|
9
|
+
* summed by day and by what the Breakdown page groups by, so a report shows this run and the history around it.
|
|
10
|
+
* Counts, names and times only: the ledger has no prompt or response text to carry.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type UsageCall = Omit<CallRecord, "v" | "proc">
|
|
14
|
+
|
|
15
|
+
/** One day's calls with the same agent, job, purpose, model, user, machine and trigger, summed. */
|
|
16
|
+
export type UsageRollup = {
|
|
17
|
+
day: string
|
|
18
|
+
agent: string
|
|
19
|
+
job: string
|
|
20
|
+
purpose: string
|
|
21
|
+
model: string
|
|
22
|
+
user: string
|
|
23
|
+
machine: string
|
|
24
|
+
trigger: string
|
|
25
|
+
calls: number
|
|
26
|
+
input: number
|
|
27
|
+
cached: number
|
|
28
|
+
cacheWrite: number
|
|
29
|
+
output: number
|
|
30
|
+
/** Cost of the priced calls, in `currency`; calls in another currency are left out of the sum. */
|
|
31
|
+
amount: number
|
|
32
|
+
currency?: string
|
|
33
|
+
unpriced: number
|
|
34
|
+
errors: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UsageData = {
|
|
38
|
+
calls: UsageCall[]
|
|
39
|
+
history: UsageRollup[]
|
|
40
|
+
historyDays: number
|
|
41
|
+
budget?: BudgetConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const HISTORY_DAYS = 30
|
|
45
|
+
|
|
46
|
+
const strip = ({ v: _v, proc: _p, ...c }: CallRecord): UsageCall => c
|
|
47
|
+
|
|
48
|
+
/** usage.json in a run folder: its calls, or none when it was not written (a run before the ledger). */
|
|
49
|
+
export function readRunUsage(runDir?: string): CallRecord[] {
|
|
50
|
+
const file = runDir ? path.join(runDir, "usage.json") : ""
|
|
51
|
+
if (!file || !existsSync(file)) return []
|
|
52
|
+
try {
|
|
53
|
+
const calls = (JSON.parse(readFileSync(file, "utf8")) as { calls?: CallRecord[] }).calls
|
|
54
|
+
return Array.isArray(calls) ? calls : []
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The ledger's last `days` days, summed per day and grouping; most recent day first. */
|
|
61
|
+
export function usageHistory(days = HISTORY_DAYS, opts: { dir?: string; now?: Date } = {}): UsageRollup[] {
|
|
62
|
+
const now = opts.now || new Date()
|
|
63
|
+
const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)))
|
|
64
|
+
const sums = new Map<string, UsageRollup>()
|
|
65
|
+
const currencies = new Map<string, number>()
|
|
66
|
+
const calls = readLedger({ since, dir: opts.dir })
|
|
67
|
+
for (const c of calls) if (c.currency) currencies.set(c.currency, (currencies.get(c.currency) || 0) + 1)
|
|
68
|
+
// One currency is summed: the one most calls were priced in.
|
|
69
|
+
const currency = [...currencies.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
|
|
70
|
+
for (const c of calls) {
|
|
71
|
+
const day = c.ts.slice(0, 10)
|
|
72
|
+
const key = [day, c.agent, c.job, c.purpose, c.model, c.user, c.machine, c.trigger].join("\u0000")
|
|
73
|
+
const r =
|
|
74
|
+
sums.get(key) ||
|
|
75
|
+
({ day, agent: c.agent, job: c.job, purpose: c.purpose, model: c.model, user: c.user, machine: c.machine, trigger: c.trigger, calls: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, amount: 0, ...(currency ? { currency } : {}), unpriced: 0, errors: 0 } as UsageRollup)
|
|
76
|
+
r.calls++
|
|
77
|
+
r.input += c.input
|
|
78
|
+
r.cached += c.cached || 0
|
|
79
|
+
r.cacheWrite += c.cacheWrite || 0
|
|
80
|
+
r.output += c.output
|
|
81
|
+
if (c.amount !== undefined && c.currency === currency) r.amount += c.amount
|
|
82
|
+
else r.unpriced++
|
|
83
|
+
if (c.outcome === "error") r.errors++
|
|
84
|
+
sums.set(key, r)
|
|
85
|
+
}
|
|
86
|
+
return [...sums.values()].sort((a, b) => b.day.localeCompare(a.day) || b.amount - a.amount)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A report's usage. A run: the calls of its folder. A suite: the calls of each case's run folder, and the calls this
|
|
91
|
+
* process made while the suite ran (ForgeQE's agent, Scribe, heals), each once. Undefined when there are none.
|
|
92
|
+
*/
|
|
93
|
+
export function reportUsage(p: { kind: "suite" | "run"; dir: string; startedAt: string; runDirs: string[]; budget?: BudgetConfig }, opts: { historyDays?: number; dir?: string } = {}): UsageData | undefined {
|
|
94
|
+
const seen = new Set<string>()
|
|
95
|
+
const calls: CallRecord[] = []
|
|
96
|
+
const add = (c: CallRecord) => {
|
|
97
|
+
if (!c?.id || seen.has(c.id)) return
|
|
98
|
+
seen.add(c.id)
|
|
99
|
+
calls.push(c)
|
|
100
|
+
}
|
|
101
|
+
const dirs = p.kind === "run" ? [p.dir, ...p.runDirs] : p.runDirs
|
|
102
|
+
for (const d of new Set(dirs)) for (const c of readRunUsage(d)) add(c)
|
|
103
|
+
if (p.kind === "run") for (const c of sessionCalls((c) => c.runId === path.basename(p.dir))) add(c)
|
|
104
|
+
if (p.kind === "suite") for (const c of sessionCalls((c) => c.ts >= p.startedAt)) add(c)
|
|
105
|
+
const history = usageHistory(opts.historyDays ?? HISTORY_DAYS, { dir: opts.dir })
|
|
106
|
+
if (!calls.length && !history.length) return undefined
|
|
107
|
+
return {
|
|
108
|
+
calls: calls.sort((a, b) => a.ts.localeCompare(b.ts)).map(strip),
|
|
109
|
+
history,
|
|
110
|
+
historyDays: opts.historyDays ?? HISTORY_DAYS,
|
|
111
|
+
...(p.budget ? { budget: p.budget } : {}),
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { ensureEngine } from "./engine/setup.ts"
|
|
|
13
13
|
import { reportEnv, writeRunReport } from "./report/html.ts"
|
|
14
14
|
import { logEvent } from "./sessionLog.ts"
|
|
15
15
|
import { costOf } from "./usage.ts"
|
|
16
|
+
import { writeRunUsage } from "./tokenomics/ledger.ts"
|
|
16
17
|
import { allLogins, redactSecrets } from "./sessionLogins.ts"
|
|
17
18
|
import { browserSkills, rememberChoice } from "./skills/select.ts"
|
|
18
19
|
import { axeTags, DEFAULT_QUALITY, emptyReport, evaluateQuality, readA11y } from "./quality/budgets.ts"
|
|
@@ -20,6 +21,7 @@ import { historyFile, recordQuality } from "./quality/history.ts"
|
|
|
20
21
|
import { compareVisual, engineVisual } from "./quality/visual.ts"
|
|
21
22
|
import { enginePrompts, prompt } from "./prompts.ts"
|
|
22
23
|
import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts"
|
|
24
|
+
import { registryMode } from "./registry/index.ts"
|
|
23
25
|
|
|
24
26
|
export type ExecuteOptions = {
|
|
25
27
|
config: AppConfig
|
|
@@ -39,6 +41,8 @@ export type ExecuteOptions = {
|
|
|
39
41
|
memory?: boolean
|
|
40
42
|
/** Steps in the test case this goal came from; the agent then reports a verdict for each. */
|
|
41
43
|
caseSteps?: number
|
|
44
|
+
/** Each case step's stepKey: after the case is edited, the engine replays the steps before the first one that changed. */
|
|
45
|
+
caseStepKeys?: string[]
|
|
42
46
|
/** A failed or cut-off run's folder (or its name under the runs folder): replay the steps that worked there first. */
|
|
43
47
|
resumeFrom?: string
|
|
44
48
|
/** Browser skills the caller chose (empty: none); unset: the engine picks the ones the goal needs. */
|
|
@@ -134,6 +138,16 @@ function shortPage(url: string): string {
|
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Page memory for the engine, or undefined to run without it. registry.mode on (the default): a replay may also be
|
|
143
|
+
* passed by its recorded checks, with no model (docs/COST_ARCHITECTURE.md); off or shadow: as it always was.
|
|
144
|
+
*/
|
|
145
|
+
export function engineMemory(opts: Pick<ExecuteOptions, "config" | "replay" | "memory">): { dir: string; replay: ReplayMode; checks_first?: boolean } | undefined {
|
|
146
|
+
const mem = opts.config.memory
|
|
147
|
+
if (!mem.enabled || opts.memory === false) return undefined
|
|
148
|
+
return { dir: mem.dir, replay: opts.replay || mem.replay, ...(registryMode(opts.config) === "on" ? { checks_first: true } : {}) }
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunResult; files: string[] }> {
|
|
138
152
|
const goal = redactSecrets(opts.goal.trim())
|
|
139
153
|
if (!goal) throw new Error("A goal is required. Pass --goal or type one in the shell.")
|
|
@@ -166,6 +180,12 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
166
180
|
} catch (err) {
|
|
167
181
|
opts.onEvent?.({ type: "log", level: "warn", text: `API steps not saved as a scenario: ${(err as Error).message}` })
|
|
168
182
|
}
|
|
183
|
+
try {
|
|
184
|
+
const usage = writeRunUsage(dir)
|
|
185
|
+
if (usage) out.files.push(usage)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
opts.onEvent?.({ type: "log", level: "warn", text: `usage.json not written: ${(err as Error).message}` })
|
|
188
|
+
}
|
|
169
189
|
logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
|
|
170
190
|
try {
|
|
171
191
|
out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed, browser }), t0))
|
|
@@ -213,8 +233,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
213
233
|
BROWSER_USE_USE_JUDGE: opts.config.browser.judge ? "true" : "false",
|
|
214
234
|
}
|
|
215
235
|
for (const [k, v] of Object.entries(tuning)) if (!process.env[k]) env[k] = v
|
|
216
|
-
const
|
|
217
|
-
const memory = mem.enabled && opts.memory !== false ? { dir: mem.dir, replay: opts.replay || mem.replay } : undefined
|
|
236
|
+
const memory = engineMemory(opts)
|
|
218
237
|
const resume = opts.resumeFrom ? loadResume(opts.config.export.outDir, opts.resumeFrom) : undefined
|
|
219
238
|
if (resume && !memory) throw new Error("resume_from replays remembered steps, so it needs page memory; it is off for this run.")
|
|
220
239
|
const q = opts.config.quality || DEFAULT_QUALITY
|
|
@@ -241,6 +260,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
241
260
|
env,
|
|
242
261
|
logins: allLogins(opts.config),
|
|
243
262
|
caseSteps: opts.caseSteps,
|
|
263
|
+
...(opts.caseRef?.caseId && opts.caseStepKeys?.length ? { caseId: opts.caseRef.caseId, caseStepKeys: opts.caseStepKeys } : {}),
|
|
244
264
|
screenshots: opts.config.report.screenshots,
|
|
245
265
|
accessibility: opts.config.report.accessibility,
|
|
246
266
|
network: opts.config.api?.capture !== false,
|
package/src/suite/run.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
4
|
import { goalFor, writeBack as postResults, type CaseResult, type TestCase, type WriteBack } from "../cases/index.ts"
|
|
5
|
-
import { costOf, type Cost } from "../cases/types.ts"
|
|
5
|
+
import { costOf, stepKey, stepsHash, type Cost } from "../cases/types.ts"
|
|
6
6
|
import { addUsage, NO_USAGE } from "../usage.ts"
|
|
7
7
|
import type { AgentEvent, CodeResult, ReplayMode, RunResult, Usage } from "../types.ts"
|
|
8
8
|
import { codeSkipReason, runCodeAgent } from "../code/index.ts"
|
|
@@ -15,7 +15,7 @@ import { reproPack, writeReproFiles } from "../cases/repro.ts"
|
|
|
15
15
|
import { commentAnalyses } from "../cases/bugs.ts"
|
|
16
16
|
import { caseOutcomes, defectHistoryFile, failedInARow, noteHistory, readDefectHistory, recordRun, writeDefectHistory } from "../cases/defectHistory.ts"
|
|
17
17
|
import { classifyFailure, FAILURE_LABEL } from "../cases/failure.ts"
|
|
18
|
-
import { associateAdoAutomation, readMappings } from "../code/traceability.ts"
|
|
18
|
+
import { associateAdoAutomation, readMappings, recordMapping } from "../code/traceability.ts"
|
|
19
19
|
import type { ExistingTest } from "../code/prompt.ts"
|
|
20
20
|
import { runTestCommand } from "../code/testrun.ts"
|
|
21
21
|
import { privateEnvNames } from "../childEnv.ts"
|
|
@@ -160,6 +160,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
160
160
|
replay: opts.replay,
|
|
161
161
|
memory: opts.memory,
|
|
162
162
|
caseSteps: tc.steps.length,
|
|
163
|
+
caseStepKeys: original.steps.map(stepKey),
|
|
163
164
|
outDir,
|
|
164
165
|
skills: opts.skills,
|
|
165
166
|
repo: opts.skillsRepo || opts.repo,
|
|
@@ -264,16 +265,20 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
264
265
|
const run = await (opts.runTest || runTestCommand)({ command: mapped.command, cwd: opts.repo!, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, hide: privateEnvNames(config), save: path.join(runDir, "existing-test") })
|
|
265
266
|
const defectNow = result.failure?.type === "functional_defect"
|
|
266
267
|
// Reused as it is: a normal test that passes on a case that passes, or a known-defect test (an expected failure)
|
|
267
|
-
// on a case that still shows the defect.
|
|
268
|
+
// on a case that still shows the defect, as long as the case's steps are the ones the test was written from.
|
|
268
269
|
const fits = mapped.status === "known_defect" ? defectNow : !defectNow
|
|
269
|
-
|
|
270
|
+
const hash = tc.steps.length ? stepsHash(tc.steps) : undefined
|
|
271
|
+
const changed = Boolean(mapped.stepsHash && hash && mapped.stepsHash !== hash)
|
|
272
|
+
if (run.ok && fits && !changed) {
|
|
273
|
+
// A mapping from before steps were fingerprinted gets this case's: a later change to the steps then shows.
|
|
274
|
+
if (hash && !mapped.stepsHash) recordMapping(opts.repo!, { ...mapped, stepsHash: hash })
|
|
270
275
|
emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "test", ok: true, attempt: 0, text: `${mapped.testFile} passes as it is: no new test needed` } })
|
|
271
276
|
result.code = { status: "passed", testFile: mapped.testFile, command: mapped.command, attempts: 0, existing: "reused", ...(mapped.testName ? { testName: mapped.testName } : {}), ...(mapped.status === "known_defect" ? { knownDefect: true } : {}) }
|
|
272
277
|
emit({ type: "code-done", index, case: tc, result })
|
|
273
278
|
return
|
|
274
279
|
}
|
|
275
280
|
if (opts.signal?.aborted) return
|
|
276
|
-
const
|
|
281
|
+
const reason =
|
|
277
282
|
mapped.status === "known_defect" && !defectNow
|
|
278
283
|
? `It is marked as an expected failure for a known defect${mapped.bug ? ` (${mapped.bug.label})` : ""}, and the browser run now passes: the defect looks fixed. Remove the expected-failure marker and keep the assertions.`
|
|
279
284
|
: defectNow && run.ok
|
|
@@ -281,6 +286,8 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
281
286
|
: defectNow
|
|
282
287
|
? "It fails because of the functional defect this run found (below): keep its assertions and mark it as an expected failure naming the defect."
|
|
283
288
|
: "It fails now: the application or the page changed since it was written."
|
|
289
|
+
const stale = "The test case's steps changed since this test was written (for example after a review): make the test do the case's steps and check its expected results as they are now, keeping the parts that still fit."
|
|
290
|
+
const why = !changed ? reason : run.ok && fits ? stale : `${stale} ${reason}`
|
|
284
291
|
existingTest = { testFile: mapped.testFile, command: mapped.command, output: run.output, why }
|
|
285
292
|
}
|
|
286
293
|
emit({ type: "code-start", index, case: tc })
|
package/src/summary.ts
CHANGED
|
@@ -62,6 +62,7 @@ export function memoryLine(m?: MemoryInfo): string {
|
|
|
62
62
|
parts.push(m.replayed === m.replayTotal ? `replayed all ${m.replayed} steps` : `replayed ${m.replayed} of ${m.replayTotal} steps`)
|
|
63
63
|
parts.push(plural(m.llmSteps, "LLM step"))
|
|
64
64
|
}
|
|
65
|
+
if (m.checked) parts.push(m.checksHeld === m.checked ? `${plural(m.checked, "recorded check")} held` : `${m.checksHeld || 0} of ${plural(m.checked, "recorded check")} held`)
|
|
65
66
|
if (m.elements) parts.push(`${plural(m.elements, "element")} on ${plural(m.pages, "page")}${m.newElements ? ` (${m.newElements} new)` : ""}`)
|
|
66
67
|
if (m.flow) parts.push(m.flow === "saved" ? "flow saved for replay" : "flow updated")
|
|
67
68
|
return parts.join(" · ")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { formatCost } from "../usage.ts"
|
|
5
|
+
import { onCall, usageDir, type CallRecord } from "./ledger.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Spending budgets (budget: in forgeqe.yaml), per run, day and month, in the price currency. They only warn, once
|
|
9
|
+
* each at 80%, 90% and 100%, and never stop a run. Spend is read from the ledger file, which every ForgeQE process
|
|
10
|
+
* on this machine appends to: a browser run the agent starts through the MCP server runs in another process, and
|
|
11
|
+
* its calls count all the same. Days and months are UTC, as the ledger's timestamps are.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type BudgetConfig = { run?: number; day?: number; month?: number; currency?: string }
|
|
15
|
+
export type BudgetPeriod = "run" | "day" | "month"
|
|
16
|
+
|
|
17
|
+
export type BudgetWarning = { period: BudgetPeriod; threshold: number; spent: number; limit: number; currency: string; text: string }
|
|
18
|
+
|
|
19
|
+
export const BUDGET_THRESHOLDS = [80, 90, 100] as const
|
|
20
|
+
|
|
21
|
+
/** How often the ledger is read again for calls other processes made. */
|
|
22
|
+
const POLL_MS = 5_000
|
|
23
|
+
|
|
24
|
+
/** budget: in forgeqe.yaml; amounts that are not positive numbers are left out. */
|
|
25
|
+
export function budgetConfig(raw: unknown): BudgetConfig | undefined {
|
|
26
|
+
const r = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null
|
|
27
|
+
if (!r) return undefined
|
|
28
|
+
const amount = (v: unknown) => (typeof v === "number" || (typeof v === "string" && v.trim() !== "") ? (Number(v) > 0 ? Number(v) : undefined) : undefined)
|
|
29
|
+
const out: BudgetConfig = {}
|
|
30
|
+
for (const k of ["run", "day", "month"] as const) {
|
|
31
|
+
const a = amount(r[k])
|
|
32
|
+
if (a !== undefined) out[k] = a
|
|
33
|
+
}
|
|
34
|
+
if (typeof r.currency === "string" && /^[A-Za-z]{3}$/.test(r.currency.trim())) out.currency = r.currency.trim().toUpperCase()
|
|
35
|
+
return out.run || out.day || out.month ? out : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BudgetStatus = { period: BudgetPeriod; spent: number; limit: number; currency: string; percent: number }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Watch spend against `cfg` and call `warn` the first time each period passes each threshold. Returns the
|
|
42
|
+
* unsubscribe and a way to read where each budget stands.
|
|
43
|
+
*/
|
|
44
|
+
export function watchBudgets(cfg: BudgetConfig, warn: (w: BudgetWarning) => void, opts: { dir?: string; now?: () => Date; pollMs?: number } = {}) {
|
|
45
|
+
const now = opts.now || (() => new Date())
|
|
46
|
+
const dir = () => opts.dir || usageDir()
|
|
47
|
+
const warned = new Set<string>()
|
|
48
|
+
const seen = new Set<string>()
|
|
49
|
+
let currency = cfg.currency
|
|
50
|
+
let month = ""
|
|
51
|
+
let offset = 0
|
|
52
|
+
let monthAmount = 0
|
|
53
|
+
const days = new Map<string, number>()
|
|
54
|
+
const runs = new Map<string, number>()
|
|
55
|
+
const touched = new Set<string>()
|
|
56
|
+
|
|
57
|
+
const add = (c: CallRecord) => {
|
|
58
|
+
if (!c?.id || seen.has(c.id) || c.amount === undefined || !c.ts?.startsWith(month)) return
|
|
59
|
+
seen.add(c.id)
|
|
60
|
+
currency ||= cfg.currency || c.currency
|
|
61
|
+
if (c.currency !== currency) return
|
|
62
|
+
monthAmount += c.amount
|
|
63
|
+
days.set(c.ts.slice(0, 10), (days.get(c.ts.slice(0, 10)) || 0) + c.amount)
|
|
64
|
+
if (c.runId) {
|
|
65
|
+
runs.set(c.runId, (runs.get(c.runId) || 0) + c.amount)
|
|
66
|
+
touched.add(c.runId)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read what was appended to this month's file since the last read; a new month starts over. */
|
|
71
|
+
const sync = () => {
|
|
72
|
+
const m = now().toISOString().slice(0, 7)
|
|
73
|
+
if (m !== month) {
|
|
74
|
+
month = m
|
|
75
|
+
offset = 0
|
|
76
|
+
monthAmount = 0
|
|
77
|
+
days.clear()
|
|
78
|
+
runs.clear()
|
|
79
|
+
seen.clear()
|
|
80
|
+
}
|
|
81
|
+
const file = path.join(dir(), `${month}.jsonl`)
|
|
82
|
+
if (!existsSync(file)) return
|
|
83
|
+
try {
|
|
84
|
+
const size = statSync(file).size
|
|
85
|
+
if (size < offset) offset = 0
|
|
86
|
+
if (size === offset) return
|
|
87
|
+
const fd = openSync(file, "r")
|
|
88
|
+
const buf = Buffer.alloc(size - offset)
|
|
89
|
+
try {
|
|
90
|
+
readSync(fd, buf, 0, buf.length, offset)
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(fd)
|
|
93
|
+
}
|
|
94
|
+
// Only whole lines: a line another process is still writing is read next time.
|
|
95
|
+
const text = buf.toString("utf8")
|
|
96
|
+
const end = text.lastIndexOf("\n")
|
|
97
|
+
if (end < 0) return
|
|
98
|
+
offset += Buffer.byteLength(text.slice(0, end + 1))
|
|
99
|
+
for (const line of text.slice(0, end).split("\n")) {
|
|
100
|
+
try {
|
|
101
|
+
add(JSON.parse(line) as CallRecord)
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const check = () => {
|
|
108
|
+
const day = now().toISOString().slice(0, 10)
|
|
109
|
+
const targets: { period: BudgetPeriod; key: string; spent: number }[] = []
|
|
110
|
+
if (cfg.day) targets.push({ period: "day", key: day, spent: days.get(day) || 0 })
|
|
111
|
+
if (cfg.month) targets.push({ period: "month", key: month, spent: monthAmount })
|
|
112
|
+
if (cfg.run) for (const id of touched) targets.push({ period: "run", key: id, spent: runs.get(id) || 0 })
|
|
113
|
+
touched.clear()
|
|
114
|
+
for (const { period, key, spent } of targets) {
|
|
115
|
+
const limit = cfg[period]!
|
|
116
|
+
// Only the highest threshold newly passed is said: a jump from 70% to 105% is one warning, not three.
|
|
117
|
+
const passed = BUDGET_THRESHOLDS.filter((t) => spent >= (limit * t) / 100 && !warned.has(`${period}:${key}:${t}`))
|
|
118
|
+
if (!passed.length) continue
|
|
119
|
+
for (const t of passed) warned.add(`${period}:${key}:${t}`)
|
|
120
|
+
const threshold = passed[passed.length - 1]!
|
|
121
|
+
const cur = currency || "USD"
|
|
122
|
+
warn({ period, threshold, spent, limit, currency: cur, text: warningText(period, threshold, spent, limit, cur) })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Spend so far this month is counted, but only what passes a threshold from now on is said.
|
|
127
|
+
sync()
|
|
128
|
+
for (const [period, key, spent] of [["day", now().toISOString().slice(0, 10), days.get(now().toISOString().slice(0, 10)) || 0], ["month", month, monthAmount]] as const) {
|
|
129
|
+
const limit = cfg[period]
|
|
130
|
+
if (limit) for (const t of BUDGET_THRESHOLDS) if (spent >= (limit * t) / 100) warned.add(`${period}:${key}:${t}`)
|
|
131
|
+
}
|
|
132
|
+
touched.clear()
|
|
133
|
+
|
|
134
|
+
const stopListening = onCall((c) => {
|
|
135
|
+
sync()
|
|
136
|
+
add(c) // the ledger is off (FORGEQE_USAGE=off) or the line was not written: count it all the same
|
|
137
|
+
check()
|
|
138
|
+
})
|
|
139
|
+
const timer = setInterval(() => {
|
|
140
|
+
sync()
|
|
141
|
+
check()
|
|
142
|
+
}, opts.pollMs ?? POLL_MS)
|
|
143
|
+
timer.unref?.()
|
|
144
|
+
|
|
145
|
+
const stop = () => {
|
|
146
|
+
stopListening()
|
|
147
|
+
clearInterval(timer)
|
|
148
|
+
}
|
|
149
|
+
const status = (runId?: string): BudgetStatus[] => {
|
|
150
|
+
sync()
|
|
151
|
+
const day = now().toISOString().slice(0, 10)
|
|
152
|
+
const out: BudgetStatus[] = []
|
|
153
|
+
const push = (period: BudgetPeriod, spent: number) => {
|
|
154
|
+
const limit = cfg[period]
|
|
155
|
+
if (limit) out.push({ period, spent, limit, currency: currency || "USD", percent: Math.round((spent / limit) * 100) })
|
|
156
|
+
}
|
|
157
|
+
if (runId) push("run", runs.get(runId) || 0)
|
|
158
|
+
push("day", days.get(day) || 0)
|
|
159
|
+
push("month", monthAmount)
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
return { stop, status }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const WHOSE: Record<BudgetPeriod, string> = { run: "this run's", day: "today's", month: "this month's" }
|
|
166
|
+
|
|
167
|
+
/** "Budget: this month's model spend is at $245.10, 82% of the $300.00 month budget (…). Nothing is stopped." */
|
|
168
|
+
export function warningText(period: BudgetPeriod, threshold: number, spent: number, limit: number, currency: string): string {
|
|
169
|
+
const money = (amount: number) => formatCost({ amount, currency })
|
|
170
|
+
const verb = threshold >= 100 ? "has reached" : "is at"
|
|
171
|
+
return `Budget: ${WHOSE[period]} model spend ${verb} ${money(spent)}, ${Math.round((spent / limit) * 100)}% of the ${money(limit)} ${period} budget (budget.${period} in forgeqe.yaml). Nothing is stopped.`
|
|
172
|
+
}
|