@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.
- package/HOW_TO_USE.md +54 -3
- 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 +66 -8
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +36 -4
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +70 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +177 -5
- package/src/api/fromSteps.ts +111 -0
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/quality.ts +3 -1
- 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/handoff.ts +20 -0
- package/src/code/loop.ts +27 -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/replay.ts +33 -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 +32 -2
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +13 -5
- package/src/summary.ts +6 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +90 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/tui/transcript.tsx +5 -4
- 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
|
+
}
|
package/src/report/data.ts
CHANGED
|
@@ -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)),
|
package/src/report/html.ts
CHANGED
|
@@ -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`
|