@capagents/forgeqe 1.2.0 → 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.
Files changed (75) hide show
  1. package/HOW_TO_USE.md +54 -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 +66 -8
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +36 -4
  9. package/prompts/browser-api.md +13 -0
  10. package/prompts/browser-capture.md +2 -2
  11. package/prompts/browser-checks.md +2 -0
  12. package/py/forgeqe_engine/api_step.py +130 -0
  13. package/py/forgeqe_engine/capture.py +53 -2
  14. package/py/forgeqe_engine/checks.py +8 -2
  15. package/py/forgeqe_engine/llm.py +33 -5
  16. package/py/forgeqe_engine/memory.py +70 -2
  17. package/py/forgeqe_engine/observe.py +123 -0
  18. package/py/forgeqe_engine/replay.py +55 -2
  19. package/py/forgeqe_engine/service.py +177 -5
  20. package/src/api/fromSteps.ts +111 -0
  21. package/src/capabilities/registry.ts +19 -5
  22. package/src/cases/ado.ts +1 -1
  23. package/src/cases/journey.ts +61 -0
  24. package/src/cases/quality.ts +3 -1
  25. package/src/cases/readiness.ts +35 -6
  26. package/src/cases/save.ts +241 -48
  27. package/src/cases/types.ts +11 -0
  28. package/src/cli/help.ts +8 -0
  29. package/src/cli/index.ts +64 -1
  30. package/src/code/coverage.ts +22 -3
  31. package/src/code/decisions.ts +73 -0
  32. package/src/code/find.ts +3 -1
  33. package/src/code/graph/graph.ts +33 -2
  34. package/src/code/handoff.ts +20 -0
  35. package/src/code/loop.ts +27 -1
  36. package/src/code/notes.ts +48 -1
  37. package/src/code/prompt.ts +31 -9
  38. package/src/code/protect.ts +103 -0
  39. package/src/code/replay.ts +33 -0
  40. package/src/code/review.ts +2 -0
  41. package/src/code/runtime.ts +35 -3
  42. package/src/code/shortlist.ts +309 -0
  43. package/src/code/testrun.ts +3 -1
  44. package/src/code/traceability.ts +2 -0
  45. package/src/commands.ts +6 -1
  46. package/src/config.ts +46 -0
  47. package/src/engine/bridge.ts +47 -5
  48. package/src/mcp/casetools.ts +107 -11
  49. package/src/mcp/commands.ts +74 -20
  50. package/src/mcp/packtools.ts +187 -0
  51. package/src/mcp/tools.ts +238 -29
  52. package/src/pack/collect.ts +251 -0
  53. package/src/pack/index.ts +134 -0
  54. package/src/pack/init.ts +45 -0
  55. package/src/pack/pack.ts +327 -0
  56. package/src/pack/pipeline.ts +373 -0
  57. package/src/pack/run.ts +155 -0
  58. package/src/registry/db.ts +249 -0
  59. package/src/registry/index.ts +72 -0
  60. package/src/registry/resolve.ts +57 -0
  61. package/src/registry/tier0.ts +81 -0
  62. package/src/report/data.ts +7 -0
  63. package/src/report/html.ts +14 -0
  64. package/src/report/template.html +52 -48
  65. package/src/report/usage.ts +113 -0
  66. package/src/run.ts +32 -2
  67. package/src/suite/report.ts +22 -4
  68. package/src/suite/run.ts +13 -5
  69. package/src/summary.ts +6 -0
  70. package/src/tokenomics/budget.ts +172 -0
  71. package/src/tokenomics/ledger.ts +287 -0
  72. package/src/tui/App.tsx +90 -2
  73. package/src/tui/inspector.tsx +40 -35
  74. package/src/tui/transcript.tsx +5 -4
  75. package/src/types.ts +11 -2
@@ -0,0 +1,249 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { createHash } from "node:crypto"
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
4
+ import path from "node:path"
5
+
6
+ /**
7
+ * The registry: one SQLite file per project that indexes what ForgeQE already knows (runs, and the plans that
8
+ * repeat them) and logs what each request cost. It is a cache over the run folders and page memory, never the
9
+ * source of truth: any error opening or reading it means "no registry", and the request takes today's path.
10
+ * See docs/COST_ARCHITECTURE.md.
11
+ */
12
+
13
+ const SCHEMA_VERSION = 1
14
+
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS entities (
17
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, source TEXT NOT NULL, external_id TEXT, title TEXT,
18
+ path TEXT, fingerprint TEXT, version TEXT, updated_at TEXT NOT NULL
19
+ );
20
+ CREATE INDEX IF NOT EXISTS entities_kind ON entities(kind);
21
+ CREATE TABLE IF NOT EXISTS aliases (
22
+ alias TEXT NOT NULL, entity_id TEXT NOT NULL, weight REAL NOT NULL DEFAULT 1, PRIMARY KEY (alias, entity_id)
23
+ );
24
+ CREATE TABLE IF NOT EXISTS plans (
25
+ id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, intent TEXT NOT NULL, tool TEXT NOT NULL, goal TEXT NOT NULL,
26
+ start_url TEXT, input_fingerprint TEXT NOT NULL, successes INTEGER NOT NULL DEFAULT 0,
27
+ failures INTEGER NOT NULL DEFAULT 0, last_outcome TEXT, last_run_dir TEXT, last_success TEXT,
28
+ first_tokens INTEGER, stale INTEGER NOT NULL DEFAULT 0, last_at TEXT NOT NULL
29
+ );
30
+ CREATE TABLE IF NOT EXISTS runs (
31
+ id TEXT PRIMARY KEY, request TEXT, mode TEXT NOT NULL, decision TEXT NOT NULL, plan_id TEXT, tier INTEGER,
32
+ outcome TEXT, tokens_in INTEGER, tokens_out INTEGER, tokens_cached INTEGER, ms INTEGER, run_dir TEXT,
33
+ started_at TEXT NOT NULL
34
+ );
35
+ CREATE INDEX IF NOT EXISTS runs_started ON runs(started_at);
36
+ `
37
+
38
+ export type Plan = {
39
+ id: string
40
+ entityId: string
41
+ goal: string
42
+ startUrl?: string
43
+ successes: number
44
+ failures: number
45
+ lastOutcome?: string
46
+ lastRunDir?: string
47
+ lastSuccess?: string
48
+ /** Input tokens of the run that recorded it: what a rerun is measured against. */
49
+ firstTokens?: number
50
+ stale: boolean
51
+ }
52
+
53
+ /** One request in the cost ledger. `mode` shadow: the decision was only recorded; the request took today's path. */
54
+ export type LedgerRow = {
55
+ request: string
56
+ mode: "shadow" | "on"
57
+ /** "tier0", or why the registry left the request to the agent ("no match", "not passed yet"). */
58
+ decision: string
59
+ planId?: string
60
+ tier?: number
61
+ outcome?: string
62
+ tokensIn?: number
63
+ tokensOut?: number
64
+ tokensCached?: number
65
+ ms?: number
66
+ runDir?: string
67
+ }
68
+
69
+ /** The text a goal is known by: the same normalisation page memory uses for its goal key (memory.py). */
70
+ export function normGoal(goal: string): string {
71
+ return goal.trim().toLowerCase().replace(/\s+/g, " ").replace(/[ .!]+$/, "")
72
+ }
73
+
74
+ export function normUrl(url?: string): string {
75
+ return (url || "").trim().replace(/\/+$/, "")
76
+ }
77
+
78
+ /** A plan's identity: what it does and where it starts. Rewording changes it; a new run of the same goal does not. */
79
+ export function planId(goal: string, startUrl?: string): string {
80
+ return `plan:${createHash("sha256").update(`${normGoal(goal)}|${normUrl(startUrl)}`).digest("hex").slice(0, 16)}`
81
+ }
82
+
83
+ /** registry.db lives in its own folder beside page memory (.forgeqe/registry), git-ignored by its own .gitignore. */
84
+ export function registryPath(memoryDir: string): string {
85
+ return path.join(path.dirname(memoryDir), "registry", "registry.db")
86
+ }
87
+
88
+ export class Registry {
89
+ private constructor(readonly db: Database) {}
90
+
91
+ /** Opens (and creates) the registry; null when it cannot, so callers fall back to today's path. */
92
+ static open(file: string): Registry | null {
93
+ try {
94
+ const dir = path.dirname(file)
95
+ mkdirSync(dir, { recursive: true })
96
+ const ignore = path.join(dir, ".gitignore")
97
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n")
98
+ const db = new Database(file, { create: true })
99
+ // Several ForgeQE processes (the shell, MCP, a monitor) may open it at once.
100
+ db.exec("PRAGMA busy_timeout = 3000; PRAGMA journal_mode = WAL;")
101
+ const version = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version
102
+ if (version > SCHEMA_VERSION) {
103
+ db.close()
104
+ return null
105
+ }
106
+ db.exec(SCHEMA)
107
+ if (version < SCHEMA_VERSION) db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
108
+ return new Registry(db)
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ close(): void {
115
+ try {
116
+ this.db.close()
117
+ } catch {}
118
+ }
119
+
120
+ hasEntity(id: string): boolean {
121
+ return Boolean(this.db.query("SELECT 1 FROM entities WHERE id = ?").get(id))
122
+ }
123
+
124
+ upsertEntity(e: { id: string; kind: string; source: string; title?: string; path?: string; fingerprint?: string; externalId?: string }): void {
125
+ this.db
126
+ .query(
127
+ `INSERT INTO entities (id, kind, source, external_id, title, path, fingerprint, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
128
+ ON CONFLICT(id) DO UPDATE SET title = excluded.title, path = excluded.path, fingerprint = excluded.fingerprint, updated_at = excluded.updated_at`,
129
+ )
130
+ .run(e.id, e.kind, e.source, e.externalId ?? null, e.title ?? null, e.path ?? null, e.fingerprint ?? null, new Date().toISOString())
131
+ }
132
+
133
+ addAlias(alias: string, entityId: string, weight = 1): void {
134
+ const a = alias.trim().toLowerCase()
135
+ if (a) this.db.query("INSERT OR IGNORE INTO aliases (alias, entity_id, weight) VALUES (?, ?, ?)").run(a, entityId, weight)
136
+ }
137
+
138
+ /** Counts one run of a plan, creating the plan on its first run. `at`: when the run happened; the latest run sets the outcome. */
139
+ recordPlanRun(p: { goal: string; startUrl?: string; passed: boolean; runDir: string; at: string; tokens?: number }): string {
140
+ const id = planId(p.goal, p.startUrl)
141
+ const outcome = p.passed ? "passed" : "failed"
142
+ this.db
143
+ .query(
144
+ `INSERT INTO plans (id, entity_id, intent, tool, goal, start_url, input_fingerprint, successes, failures, last_outcome, last_run_dir, last_success, first_tokens, last_at)
145
+ VALUES ($id, $id, 'browser_goal', 'browser_run', $goal, $url, $id, $ok, $bad, $outcome, $dir, $success, $tokens, $at)
146
+ ON CONFLICT(id) DO UPDATE SET
147
+ successes = successes + $ok,
148
+ failures = failures + $bad,
149
+ last_outcome = CASE WHEN $at >= last_at THEN $outcome ELSE last_outcome END,
150
+ last_run_dir = CASE WHEN $at >= last_at THEN $dir ELSE last_run_dir END,
151
+ last_success = CASE WHEN $ok = 1 AND $at > COALESCE(last_success, '') THEN $at ELSE last_success END,
152
+ first_tokens = COALESCE(first_tokens, $tokens),
153
+ last_at = MAX(last_at, $at)`,
154
+ )
155
+ .run({ $id: id, $goal: p.goal, $url: p.startUrl ?? null, $ok: p.passed ? 1 : 0, $bad: p.passed ? 0 : 1, $outcome: outcome, $dir: p.runDir, $success: p.passed ? p.at : null, $tokens: p.tokens ?? null, $at: p.at })
156
+ return id
157
+ }
158
+
159
+ plan(id: string): Plan | null {
160
+ const r = this.db.query("SELECT * FROM plans WHERE id = ?").get(id) as Record<string, unknown> | null
161
+ return r ? toPlan(r) : null
162
+ }
163
+
164
+ /** Plans whose goal or run folder is known by `alias`, most recently run first. */
165
+ plansByAlias(alias: string): Plan[] {
166
+ const rows = this.db
167
+ .query(
168
+ `SELECT p.* FROM aliases a JOIN plans p ON p.id = a.entity_id WHERE a.alias = ?
169
+ UNION SELECT p.* FROM aliases a JOIN entities e ON e.id = a.entity_id JOIN plans p ON p.last_run_dir = e.path WHERE a.alias = ? AND e.kind = 'run'
170
+ ORDER BY last_at DESC`,
171
+ )
172
+ .all(alias.trim().toLowerCase(), alias.trim().toLowerCase()) as Record<string, unknown>[]
173
+ return rows.map(toPlan)
174
+ }
175
+
176
+ /** The plan of the most recent run, whatever its outcome. */
177
+ latestPlan(): Plan | null {
178
+ const r = this.db.query("SELECT * FROM plans ORDER BY last_at DESC LIMIT 1").get() as Record<string, unknown> | null
179
+ return r ? toPlan(r) : null
180
+ }
181
+
182
+ /** The plan a run folder (or its name) recorded. */
183
+ planOfRun(run: string): Plan | null {
184
+ const r = this.db.query("SELECT p.* FROM entities e JOIN plans p ON p.id = e.fingerprint WHERE e.kind = 'run' AND (e.id = ? OR e.path = ?)").get(`run:${path.basename(run)}`, path.resolve(run)) as Record<
185
+ string,
186
+ unknown
187
+ > | null
188
+ return r ? toPlan(r) : null
189
+ }
190
+
191
+ log(row: LedgerRow): string {
192
+ const id = `req:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
193
+ this.db
194
+ .query(
195
+ `INSERT INTO runs (id, request, mode, decision, plan_id, tier, outcome, tokens_in, tokens_out, tokens_cached, ms, run_dir, started_at)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
197
+ )
198
+ .run(
199
+ id,
200
+ row.request,
201
+ row.mode,
202
+ row.decision,
203
+ row.planId ?? null,
204
+ row.tier ?? null,
205
+ row.outcome ?? null,
206
+ row.tokensIn ?? null,
207
+ row.tokensOut ?? null,
208
+ row.tokensCached ?? null,
209
+ row.ms ?? null,
210
+ row.runDir ?? null,
211
+ new Date().toISOString(),
212
+ )
213
+ return id
214
+ }
215
+
216
+ ledger(limit = 50): (LedgerRow & { id: string; startedAt: string })[] {
217
+ const rows = this.db.query("SELECT * FROM runs ORDER BY started_at DESC LIMIT ?").all(limit) as Record<string, unknown>[]
218
+ return rows.map((r) => ({
219
+ id: String(r.id),
220
+ startedAt: String(r.started_at),
221
+ request: String(r.request ?? ""),
222
+ mode: r.mode as LedgerRow["mode"],
223
+ decision: String(r.decision),
224
+ ...(r.plan_id ? { planId: String(r.plan_id) } : {}),
225
+ ...(r.tier != null ? { tier: Number(r.tier) } : {}),
226
+ ...(r.outcome ? { outcome: String(r.outcome) } : {}),
227
+ ...(r.tokens_in != null ? { tokensIn: Number(r.tokens_in) } : {}),
228
+ ...(r.tokens_out != null ? { tokensOut: Number(r.tokens_out) } : {}),
229
+ ...(r.ms != null ? { ms: Number(r.ms) } : {}),
230
+ ...(r.run_dir ? { runDir: String(r.run_dir) } : {}),
231
+ }))
232
+ }
233
+ }
234
+
235
+ function toPlan(r: Record<string, unknown>): Plan {
236
+ return {
237
+ id: String(r.id),
238
+ entityId: String(r.entity_id),
239
+ goal: String(r.goal),
240
+ ...(r.start_url ? { startUrl: String(r.start_url) } : {}),
241
+ successes: Number(r.successes || 0),
242
+ failures: Number(r.failures || 0),
243
+ ...(r.last_outcome ? { lastOutcome: String(r.last_outcome) } : {}),
244
+ ...(r.last_run_dir ? { lastRunDir: String(r.last_run_dir) } : {}),
245
+ ...(r.last_success ? { lastSuccess: String(r.last_success) } : {}),
246
+ ...(r.first_tokens != null ? { firstTokens: Number(r.first_tokens) } : {}),
247
+ stale: Boolean(r.stale),
248
+ }
249
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { AppConfig } from "../config.ts"
5
+ import { normGoal, Registry, registryPath } from "./db.ts"
6
+
7
+ export { normGoal, planId, Registry, registryPath, type LedgerRow, type Plan } from "./db.ts"
8
+
9
+ /** registry.mode in forgeqe.yaml: on by default; off leaves every request to the agent; shadow only records. */
10
+ export type RegistryMode = "off" | "shadow" | "on"
11
+
12
+ export function registryMode(cfg: Pick<AppConfig, "registry">): RegistryMode {
13
+ return cfg.registry?.mode || "on"
14
+ }
15
+
16
+ /** The project's registry with its run folders indexed, or null when it cannot be opened (today's path then). */
17
+ export function openRegistry(cfg: AppConfig): Registry | null {
18
+ const reg = Registry.open(registryPath(cfg.memory.dir))
19
+ if (!reg) return null
20
+ try {
21
+ indexRuns(reg, cfg.export.outDir)
22
+ } catch {}
23
+ return reg
24
+ }
25
+
26
+ type RunJson = { goal?: string; start_url?: string; outcome?: string; status?: string; success?: boolean; usage?: { input?: number } }
27
+
28
+ /** A run passed when its record says so: the engine's outcome, or ForgeQE's own status. */
29
+ export function runPassed(run: RunJson): boolean {
30
+ if (run.outcome) return run.outcome === "passed"
31
+ if (run.status) return run.status === "passed" || run.status === "complete"
32
+ return run.success === true
33
+ }
34
+
35
+ /**
36
+ * Adds the run folders the registry has not seen: each run, and the plan that repeats it (its goal and start URL).
37
+ * Reads run.json only; never writes to a run folder. Folders already indexed are skipped, so this is cheap to repeat.
38
+ */
39
+ export function indexRuns(reg: Registry, outDir: string): number {
40
+ if (!existsSync(outDir)) return 0
41
+ let added = 0
42
+ const names = readdirSync(outDir).sort()
43
+ reg.db.exec("BEGIN")
44
+ try {
45
+ for (const name of names) {
46
+ const dir = path.join(outDir, name)
47
+ const file = path.join(dir, "run.json")
48
+ const id = `run:${name}`
49
+ if (reg.hasEntity(id) || !existsSync(file)) continue
50
+ let run: RunJson
51
+ try {
52
+ run = JSON.parse(readFileSync(file, "utf8")) as RunJson
53
+ } catch {
54
+ continue
55
+ }
56
+ const goal = String(run.goal || "").trim()
57
+ if (!goal) continue
58
+ const at = statSync(file).mtime.toISOString()
59
+ const plan = reg.recordPlanRun({ goal, startUrl: run.start_url || undefined, passed: runPassed(run), runDir: dir, at, tokens: run.usage?.input })
60
+ reg.upsertEntity({ id, kind: "run", source: "reports", title: goal, path: dir, fingerprint: plan })
61
+ reg.addAlias(name, id)
62
+ reg.addAlias(normGoal(goal), plan)
63
+ added++
64
+ }
65
+ reg.db.exec("COMMIT")
66
+ } catch (err) {
67
+ reg.db.exec("ROLLBACK")
68
+ throw err
69
+ }
70
+ return added
71
+ }
72
+
@@ -0,0 +1,57 @@
1
+ import { normGoal, normUrl, type Plan, type Registry } from "./db.ts"
2
+
3
+ /**
4
+ * The resolver: which requests the registry can answer without ForgeQE's agent, using no model at all. It is
5
+ * deliberately narrow. It claims a request only when it names something already run (the rerun link, /rerun, "run
6
+ * it again", a run folder, or a goal word for word) and that run passed. Anything it is unsure of goes to the agent.
7
+ */
8
+
9
+ export type Decision =
10
+ /** Tier 0: run the plan as recorded, through browser_run, with no agent. */
11
+ | { tier: 0; plan: Plan; why: string }
12
+ /** Left to the agent; `plan` when one matched but cannot run at Tier 0. */
13
+ | { tier: null; why: string; plan?: Plan }
14
+
15
+ /** The text of the rerun link on the start screen. */
16
+ const LINK = /^run this goal again:\s*([\s\S]+)$/i
17
+ /** "run it again", "rerun the last test", "repeat 20261014-162859 once more". */
18
+ const AGAIN = /^(?:please\s+)?(?:re-?run|run|repeat)\s+([\s\S]+?)\s+(?:again|once more)[.!]?$/i
19
+ const RERUN = /^(?:please\s+)?re-?run\s+([\s\S]+?)[.!]?$/i
20
+ const BARE = /^(?:please\s+)?(?:re-?run|run (?:it |that )?again|again)[.!]?$/i
21
+ const LAST = /^(?:the\s+)?(?:last|previous|latest)(?:\s+(?:run|test|one|goal))?$|^(?:it|that|this)$/i
22
+
23
+ /** What the request asks to run again, or null when it is not a request to repeat something. */
24
+ export function rerunTarget(text: string): { target: string; explicit: boolean } | null {
25
+ const t = text.trim()
26
+ if (BARE.test(t)) return { target: "last", explicit: true }
27
+ const m = LINK.exec(t) || AGAIN.exec(t) || RERUN.exec(t)
28
+ if (m) return { target: m[1]!.trim(), explicit: true }
29
+ // Not phrased as a rerun: it can still be a known goal, typed or pasted word for word.
30
+ return { target: t, explicit: false }
31
+ }
32
+
33
+ /** Decides one request. */
34
+ export function resolveRequest(reg: Registry, text: string): Decision {
35
+ const r = rerunTarget(text)
36
+ if (!r) return { tier: null, why: "not a rerun" }
37
+ return resolveTarget(reg, r.target, r.explicit)
38
+ }
39
+
40
+ /** `explicit`: the person asked to run it again; otherwise only an exact goal can match. */
41
+ export function resolveTarget(reg: Registry, target: string, explicit = true): Decision {
42
+ let plan: Plan | null = null
43
+ if (explicit && (LAST.test(target) || target === "last")) plan = reg.latestPlan()
44
+ else {
45
+ if (explicit) plan = reg.planOfRun(target)
46
+ if (!plan) {
47
+ const found = reg.plansByAlias(normGoal(target))
48
+ // The same goal recorded from more than one start page: which one is meant is the agent's call.
49
+ if (new Set(found.map((p) => normUrl(p.startUrl))).size > 1) return { tier: null, why: "more than one recorded start page for this goal" }
50
+ plan = found[0] || null
51
+ }
52
+ }
53
+ if (!plan) return { tier: null, why: explicit ? "no recorded run matches" : "not a recorded goal" }
54
+ if (plan.lastOutcome !== "passed") return { tier: null, why: "its last run did not pass", plan }
55
+ if (plan.stale) return { tier: null, why: "marked stale", plan }
56
+ return { tier: 0, plan, why: explicit ? "rerun of a passed run" : "a recorded goal word for word" }
57
+ }
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { Plan } from "./db.ts"
5
+
6
+ /** What browser_run returns, as far as a Tier 0 reply needs it: a run's summary, or an existing test that passed. */
7
+ export type RunSummary = {
8
+ status?: string
9
+ summary?: string
10
+ run_dir?: string
11
+ outcome?: string
12
+ failed_expectations?: { expected?: string; page_shows?: string }[]
13
+ tokens?: number
14
+ memory?: { replayed?: number; llm_steps?: number }
15
+ reused_test?: string
16
+ test?: string
17
+ command?: string
18
+ seconds?: number
19
+ /** From the run's run.json: recorded checks found on the page again, and how many held. */
20
+ checked?: number
21
+ checks_held?: number
22
+ }
23
+
24
+ /** What a run's page memory says about its checks (run.json), for the reply; nothing when it cannot be read. */
25
+ export function runChecks(runDir?: string): Pick<RunSummary, "checked" | "checks_held"> {
26
+ try {
27
+ const file = runDir ? path.join(runDir, "run.json") : ""
28
+ if (!file || !existsSync(file)) return {}
29
+ const m = (JSON.parse(readFileSync(file, "utf8")) as { memory?: { checked?: number; checks_held?: number } }).memory
30
+ return m?.checked ? { checked: Number(m.checked), checks_held: Number(m.checks_held || 0) } : {}
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ /** The reply to a Tier 0 rerun, written from the result: no model is asked to summarise it. */
37
+ export function tier0Reply(plan: Plan, out: RunSummary): { text: string; outcome: "done" | "failed" } {
38
+ const tokens = out.tokens || 0
39
+ const share = plan.firstTokens ? ` (${((tokens / plan.firstTokens) * 100).toFixed(tokens && tokens < plan.firstTokens / 100 ? 2 : 0)}% of the ${fmt(plan.firstTokens)} the first run used)` : ""
40
+ const cost = `Run again as recorded, without ForgeQE's agent · ${tokens ? `${fmt(tokens)} tokens` : "no model tokens"}${share}.`
41
+ if (out.reused_test) {
42
+ const which = out.test ? `${out.test} in ${out.reused_test}` : out.reused_test
43
+ return { text: `Passed: the existing test ${which} ran as it is (${out.command || "its test command"}${out.seconds ? `, ${out.seconds}s` : ""}).\n${cost}`, outcome: "done" }
44
+ }
45
+ const n = out.memory?.replayed || 0
46
+ const llm = out.memory?.llm_steps || 0
47
+ const replay = n
48
+ ? ` Replayed ${n} remembered step${n === 1 ? "" : "s"} without the model${llm ? `; the browser agent then took ${llm} step${llm === 1 ? "" : "s"} with it` : ""}.`
49
+ : llm
50
+ ? ` The browser agent took ${llm} step${llm === 1 ? "" : "s"} with the model.`
51
+ : ""
52
+ const checks = out.checked
53
+ ? out.checks_held === out.checked
54
+ ? ` ${out.checked === 1 ? "The recorded check was" : `All ${out.checked} recorded checks were`} found on the page again${llm ? "" : ", so no model was needed to judge it"}.`
55
+ : ` ${out.checks_held || 0} of ${out.checked} recorded checks found on the page again.`
56
+ : ""
57
+ const where = out.run_dir ? `\nReport: ${out.run_dir}` : ""
58
+ if (out.status === "passed") return { text: `Passed: ${clip(plan.goal)}${replay}${checks}${where}\n${cost}`, outcome: "done" }
59
+ const failed = out.failed_expectations?.length
60
+ ? `\n${out.failed_expectations.map((f) => `• Expected ${f.expected || "?"}; the page shows ${f.page_shows || "something else"}`).join("\n")}`
61
+ : out.summary
62
+ ? `\n${out.summary}`
63
+ : ""
64
+ const verb = out.status === "stopped" ? "Stopped" : out.outcome === "assertion_failed" ? "Failed: an expected result does not hold" : "Failed"
65
+ return {
66
+ text: `${verb}: ${clip(plan.goal)}${replay}${checks}${failed}${where}\n${cost}\nIt passed before, so ask ForgeQE to look into it: its agent can replay what still works and find what changed.`,
67
+ outcome: "failed",
68
+ }
69
+ }
70
+
71
+ /** A long goal, cut at a word near 160 characters: the report has all of it. */
72
+ function clip(goal: string, max = 160): string {
73
+ const g = goal.replace(/\s+/g, " ").trim()
74
+ if (g.length <= max) return g
75
+ const cut = g.slice(0, max)
76
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 20))}…`
77
+ }
78
+
79
+ function fmt(n: number): string {
80
+ return n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}K` : String(n)
81
+ }
@@ -8,6 +8,7 @@ import { DEFECT_HISTORY } from "../cases/defectHistory.ts"
8
8
  import { defectTrends, type DefectTrends } from "../cases/predict.ts"
9
9
  import type { ReportEnv } from "./html.ts"
10
10
  import { addCost } from "../usage.ts"
11
+ import { reportUsage, type UsageData } from "./usage.ts"
11
12
  import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
12
13
 
13
14
  /** Something that went wrong in the browser during a step: a console error, an uncaught exception, a failed or slow request. */
@@ -113,6 +114,8 @@ export type ReportData = {
113
114
  defects?: DefectCluster[]
114
115
  /** How the defects of the suites in this folder went over time, when they have a history. */
115
116
  defectTrends?: DefectTrends
117
+ /** Every model call of the report and the ledger's recent days (src/report/usage.ts); unset when there are none. */
118
+ usage?: UsageData
116
119
  }
117
120
 
118
121
  export type ReportCase = {
@@ -258,6 +261,10 @@ export function buildReportData(p: DataInput, opts: DataOptions = {}): ReportDat
258
261
  data.defectTrends = defectTrends(path.dirname(p.dir))
259
262
  } catch {}
260
263
  }
264
+ try {
265
+ const usage = reportUsage({ kind: p.kind, dir: p.dir, startedAt: p.startedAt, runDirs: p.results.map((r) => r.runDir).filter((d): d is string => Boolean(d)), budget: p.env?.budget })
266
+ if (usage) data.usage = usage
267
+ } catch {}
261
268
  if (p.kind === "suite") {
262
269
  data.history = [
263
270
  ...earlier.map((s) => historyRun(s.name, s.title, s.startedAt, s.ms, s.results, s.href)),
@@ -7,6 +7,7 @@ import { costOf, type CaseResult, type WriteBack } from "../cases/types.ts"
7
7
  import type { AppConfig, BrowserConfig } from "../config.ts"
8
8
  import type { DefectCluster } from "../cases/defects.ts"
9
9
  import { packageVersion } from "../tui/theme.ts"
10
+ import type { BudgetConfig } from "../tokenomics/budget.ts"
10
11
  import { buildReportData, type DataOptions } from "./data.ts"
11
12
 
12
13
  export { readTimeline, type TimelineStep } from "./data.ts"
@@ -23,6 +24,10 @@ export type ReportEnv = {
23
24
  branch?: string
24
25
  /** Price per million tokens of the browser agent's model, when set in llms.json. */
25
26
  price?: { input: number; output: number; currency: string; cachedInput?: number; note?: string; estimate?: boolean }
27
+ /** budget: in forgeqe.yaml, for the Tokenomics pages. */
28
+ budget?: BudgetConfig
29
+ /** The profiles in llms.json that have a price: the models the Tokenomics calculator can price a run at. */
30
+ profiles?: { name: string; provider: string; model: string; price: NonNullable<ReportEnv["price"]> }[]
26
31
  }
27
32
 
28
33
  export function ciInfo(env = process.env): Pick<ReportEnv, "ci" | "commit" | "branch"> {
@@ -58,10 +63,19 @@ export function reportEnv(
58
63
  version: packageVersion(),
59
64
  machine: `${os.hostname()} · ${process.platform}`,
60
65
  ...(llm.price ? { price: llm.price } : {}),
66
+ ...(config.budget ? { budget: config.budget } : {}),
67
+ ...(priced(config).length ? { profiles: priced(config) } : {}),
61
68
  ...ciInfo(),
62
69
  }
63
70
  }
64
71
 
72
+ /** Profiles with a price, by name; the key and endpoint stay out of the report. */
73
+ function priced(config: AppConfig): NonNullable<ReportEnv["profiles"]> {
74
+ return Object.entries(config.profiles || {})
75
+ .filter(([, p]) => p.price && p.provider !== "mock" && p.provider !== "none")
76
+ .map(([name, p]) => ({ name, provider: p.provider, model: p.deployment || p.model, price: p.price! }))
77
+ }
78
+
65
79
  export function duration(ms: number): string {
66
80
  const s = Math.round(ms / 1000)
67
81
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`