@capagents/forgeqe 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/HOW_TO_USE.md +40 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +54 -7
  6. package/evals/prompts/brain/route-api-scenarios.yaml +9 -0
  7. package/evals/prompts/brain/route-e2e.yaml +10 -0
  8. package/evals/prompts/brain/route-flaky.yaml +9 -0
  9. package/evals/prompts/brain/route-heal.yaml +9 -0
  10. package/evals/prompts/brain/route-regression-pack.yaml +9 -0
  11. package/evals/prompts/brain/route-review-update.yaml +10 -0
  12. package/evals/prompts/brain/route-run-cases.yaml +9 -0
  13. package/evals/prompts/brain/route-story-cases.yaml +12 -0
  14. package/forgeqe.example.yaml +15 -0
  15. package/package.json +1 -1
  16. package/prompts/README.md +30 -0
  17. package/prompts/brain.md +34 -49
  18. package/prompts/browser-checks.md +2 -0
  19. package/py/forgeqe_engine/checks.py +8 -2
  20. package/py/forgeqe_engine/llm.py +33 -5
  21. package/py/forgeqe_engine/memory.py +59 -2
  22. package/py/forgeqe_engine/observe.py +123 -0
  23. package/py/forgeqe_engine/replay.py +55 -2
  24. package/py/forgeqe_engine/service.py +155 -3
  25. package/src/agents.ts +52 -0
  26. package/src/brain/index.ts +8 -2
  27. package/src/capabilities/registry.ts +48 -6
  28. package/src/cases/ado.ts +1 -1
  29. package/src/cases/journey.ts +61 -0
  30. package/src/cases/readiness.ts +35 -6
  31. package/src/cases/save.ts +241 -48
  32. package/src/cases/types.ts +11 -0
  33. package/src/cli/help.ts +9 -0
  34. package/src/cli/index.ts +71 -1
  35. package/src/code/coverage.ts +22 -3
  36. package/src/code/decisions.ts +73 -0
  37. package/src/code/find.ts +3 -1
  38. package/src/code/graph/graph.ts +33 -2
  39. package/src/code/loop.ts +17 -1
  40. package/src/code/notes.ts +48 -1
  41. package/src/code/prompt.ts +31 -9
  42. package/src/code/protect.ts +103 -0
  43. package/src/code/review.ts +2 -0
  44. package/src/code/runtime.ts +35 -3
  45. package/src/code/shortlist.ts +309 -0
  46. package/src/code/testrun.ts +3 -1
  47. package/src/code/traceability.ts +2 -0
  48. package/src/commands.ts +6 -1
  49. package/src/config.ts +46 -0
  50. package/src/context.ts +75 -0
  51. package/src/engine/bridge.ts +47 -5
  52. package/src/mcp/captools.ts +19 -0
  53. package/src/mcp/casetools.ts +107 -11
  54. package/src/mcp/commands.ts +74 -20
  55. package/src/mcp/packtools.ts +187 -0
  56. package/src/mcp/routing.ts +145 -0
  57. package/src/mcp/server.ts +7 -3
  58. package/src/mcp/tools.ts +241 -29
  59. package/src/pack/collect.ts +251 -0
  60. package/src/pack/index.ts +134 -0
  61. package/src/pack/init.ts +45 -0
  62. package/src/pack/pack.ts +327 -0
  63. package/src/pack/pipeline.ts +373 -0
  64. package/src/pack/run.ts +155 -0
  65. package/src/registry/db.ts +249 -0
  66. package/src/registry/index.ts +72 -0
  67. package/src/registry/resolve.ts +57 -0
  68. package/src/registry/tier0.ts +81 -0
  69. package/src/report/data.ts +7 -0
  70. package/src/report/html.ts +14 -0
  71. package/src/report/template.html +52 -48
  72. package/src/report/usage.ts +113 -0
  73. package/src/run.ts +22 -2
  74. package/src/suite/run.ts +12 -5
  75. package/src/summary.ts +1 -0
  76. package/src/tokenomics/budget.ts +172 -0
  77. package/src/tokenomics/ledger.ts +287 -0
  78. package/src/tui/App.tsx +89 -2
  79. package/src/tui/inspector.tsx +40 -35
  80. 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 mem = opts.config.memory
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
- if (run.ok && fits) {
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 why =
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
+ }