@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,287 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
import { forgeqeHome } from "../tools.ts"
|
|
8
|
+
import type { Usage } from "../types.ts"
|
|
9
|
+
import { costOf, recordUsage, type Price, type Role } from "../usage.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The usage ledger: one record per model call, by every agent, kept in ~/.forgeqe/usage/YYYY-MM.jsonl so the
|
|
13
|
+
* Tokenomics report can say where tokens went: by agent, job, task, purpose, model, user and machine, over time.
|
|
14
|
+
* A record holds counts, times and names only, never prompt or response text. FORGEQE_USAGE=off keeps nothing on
|
|
15
|
+
* disk (the meter in usage.ts still counts); FORGEQE_USAGE_DIR moves the files.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type Trigger = "tui" | "cli" | "mcp" | "ci" | "monitor" | "schedule"
|
|
19
|
+
|
|
20
|
+
/** What a call was for, from the tools it asked for: no tools is the model thinking or answering. */
|
|
21
|
+
export type Purpose = "reasoning" | "planning" | "search" | "file read" | "edit" | "shell" | "browser" | "integration" | "delegation" | "unattributed"
|
|
22
|
+
|
|
23
|
+
/** What ForgeQE was doing when the call was made. Set with withUsageScope; inner scopes add to outer ones. */
|
|
24
|
+
export type UsageScope = {
|
|
25
|
+
/** The kind of work: "browser test", "heal", "write tests", "pr review", "eval", … */
|
|
26
|
+
job?: string
|
|
27
|
+
/** The piece of it: a session title, a case, a heal attempt. */
|
|
28
|
+
task?: string
|
|
29
|
+
runId?: string
|
|
30
|
+
caseId?: string
|
|
31
|
+
attempt?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CallRecord = {
|
|
35
|
+
v: 1
|
|
36
|
+
id: string
|
|
37
|
+
/** When the call finished (ISO). */
|
|
38
|
+
ts: string
|
|
39
|
+
ms?: number
|
|
40
|
+
agent: Role
|
|
41
|
+
job: string
|
|
42
|
+
task?: string
|
|
43
|
+
purpose: Purpose
|
|
44
|
+
/** The tools this call asked for, by name, in order, without repeats. */
|
|
45
|
+
tools?: string[]
|
|
46
|
+
runId?: string
|
|
47
|
+
caseId?: string
|
|
48
|
+
attempt?: number
|
|
49
|
+
step?: number
|
|
50
|
+
/** The agent runtime's session, or the engine's run. */
|
|
51
|
+
session?: string
|
|
52
|
+
provider?: string
|
|
53
|
+
model: string
|
|
54
|
+
/** Every prompt token, cached ones included (as Usage). */
|
|
55
|
+
input: number
|
|
56
|
+
cached?: number
|
|
57
|
+
cacheWrite?: number
|
|
58
|
+
/** Completion tokens, reasoning included. */
|
|
59
|
+
output: number
|
|
60
|
+
reasoning?: number
|
|
61
|
+
amount?: number
|
|
62
|
+
currency?: string
|
|
63
|
+
/** No price was known for the model: amount is missing. */
|
|
64
|
+
unpriced?: boolean
|
|
65
|
+
/** Part of the price is derived, or cached input was priced at the full rate. */
|
|
66
|
+
estimate?: boolean
|
|
67
|
+
outcome: "ok" | "error" | "cancelled"
|
|
68
|
+
user: string
|
|
69
|
+
machine: string
|
|
70
|
+
trigger: Trigger
|
|
71
|
+
/** One ForgeQE process: tells apart calls from two shells running at once. */
|
|
72
|
+
proc: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const scopes = new AsyncLocalStorage<UsageScope>()
|
|
76
|
+
|
|
77
|
+
/** Run `fn` with `scope` added to the current one, for every model call made inside it (awaited work included). */
|
|
78
|
+
export function withUsageScope<T>(scope: UsageScope, fn: () => T): T {
|
|
79
|
+
return scopes.run({ ...scopes.getStore(), ...scope }, fn)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function currentUsageScope(): UsageScope {
|
|
83
|
+
return { ...scopes.getStore() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let trigger: Trigger | null = null
|
|
87
|
+
|
|
88
|
+
/** How this process was started; CI is told from the environment. Child processes (the MCP server) inherit it. */
|
|
89
|
+
export function setUsageTrigger(t: Trigger): void {
|
|
90
|
+
trigger = t
|
|
91
|
+
process.env.FORGEQE_TRIGGER ||= t
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function triggerNow(): Trigger {
|
|
95
|
+
const env = process.env.FORGEQE_TRIGGER as Trigger | undefined
|
|
96
|
+
if (env) return env
|
|
97
|
+
if (process.env.CI || process.env.TF_BUILD || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) return "ci"
|
|
98
|
+
return trigger || "cli"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function who(): { user: string; machine: string } {
|
|
102
|
+
let user = process.env.USER || process.env.USERNAME || ""
|
|
103
|
+
try {
|
|
104
|
+
user = os.userInfo().username || user
|
|
105
|
+
} catch {}
|
|
106
|
+
return { user: user || "unknown", machine: os.hostname() || "unknown" }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const PROC = randomUUID().slice(0, 8)
|
|
110
|
+
|
|
111
|
+
const PURPOSES: [RegExp, Purpose][] = [
|
|
112
|
+
[/^(edit|write|patch|multiedit|apply_patch)$/i, "edit"],
|
|
113
|
+
[/(^|_)(browser_run|run_test|run_test_cases|page_memory)$/i, "browser"],
|
|
114
|
+
[/^bash$/i, "shell"],
|
|
115
|
+
[/^(grep|glob|codesearch|websearch|webfetch)$|(^|_)(find_test_cases|query_api|read_confluence|search|find|rag)/i, "search"],
|
|
116
|
+
[/^(read|list|ls)$/i, "file read"],
|
|
117
|
+
[/^task$/i, "delegation"],
|
|
118
|
+
[/^(todowrite|todoread)$/i, "planning"],
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** What a call was for, from its tools: the first matching kind in PURPOSES' order wins; other tools are integrations. */
|
|
122
|
+
export function purposeOf(tools: string[] | undefined): Purpose {
|
|
123
|
+
const named = (tools || []).filter((t) => t && t !== "StructuredOutput")
|
|
124
|
+
if (!named.length) return "reasoning"
|
|
125
|
+
for (const [re, purpose] of PURPOSES) if (named.some((t) => re.test(t))) return purpose
|
|
126
|
+
return named.some((t) => t === "skill") ? "file read" : "integration"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What a call made without a scope was most likely part of, by the agent that made it. */
|
|
130
|
+
const DEFAULT_JOB: Record<Role, string> = {
|
|
131
|
+
"ForgeQE's agent": "chat",
|
|
132
|
+
"browser agent": "browser test",
|
|
133
|
+
Scribe: "write tests",
|
|
134
|
+
reviewer: "review",
|
|
135
|
+
judge: "eval",
|
|
136
|
+
refiner: "refine",
|
|
137
|
+
"root-cause analyst": "defect rca",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function usageDir(): string {
|
|
141
|
+
return process.env.FORGEQE_USAGE_DIR || path.join(forgeqeHome(), "usage")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const keeping = () => !/^(0|off|false|no)$/i.test(process.env.FORGEQE_USAGE || "")
|
|
145
|
+
|
|
146
|
+
const monthFile = (d: Date) => path.join(usageDir(), `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}.jsonl`)
|
|
147
|
+
|
|
148
|
+
/** This process's calls, oldest first (at most MAX_KEPT). */
|
|
149
|
+
const calls: CallRecord[] = []
|
|
150
|
+
const MAX_KEPT = 50_000
|
|
151
|
+
const listeners = new Set<(c: CallRecord) => void>()
|
|
152
|
+
|
|
153
|
+
/** Called with every call recorded in this process; returns the unsubscribe. */
|
|
154
|
+
export function onCall(listener: (c: CallRecord) => void): () => void {
|
|
155
|
+
listeners.add(listener)
|
|
156
|
+
return () => listeners.delete(listener)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type CallInput = {
|
|
160
|
+
agent: Role
|
|
161
|
+
usage: Usage
|
|
162
|
+
price: Price | undefined
|
|
163
|
+
model: string
|
|
164
|
+
provider?: string
|
|
165
|
+
ms?: number
|
|
166
|
+
tools?: string[]
|
|
167
|
+
purpose?: Purpose
|
|
168
|
+
reasoning?: number
|
|
169
|
+
session?: string
|
|
170
|
+
step?: number
|
|
171
|
+
/** The task when the scope names none (the agent runtime's session title). */
|
|
172
|
+
task?: string
|
|
173
|
+
outcome?: CallRecord["outcome"]
|
|
174
|
+
/** A cost the caller already knows (the agent runtime's own), used when `price` is unknown. */
|
|
175
|
+
knownCost?: { amount: number; currency: string }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Record one model call: counted in the meter by its agent, kept in this process's list, and appended to the
|
|
180
|
+
* month's ledger file. Never throws: a ledger that cannot be written loses the record, never the run.
|
|
181
|
+
*/
|
|
182
|
+
export function recordCall(c: CallInput): CallRecord | null {
|
|
183
|
+
if (!c.usage.input && !c.usage.output) return null
|
|
184
|
+
recordUsage(c.agent, c.usage, c.price)
|
|
185
|
+
const scope = scopes.getStore() || {}
|
|
186
|
+
const cost = costOf(c.usage, c.price)
|
|
187
|
+
const fallback = !cost && c.knownCost && c.knownCost.amount > 0 ? c.knownCost : undefined
|
|
188
|
+
const now = new Date()
|
|
189
|
+
const tools = c.tools?.length ? [...new Set(c.tools)] : undefined
|
|
190
|
+
const rec: CallRecord = {
|
|
191
|
+
v: 1,
|
|
192
|
+
id: randomUUID(),
|
|
193
|
+
ts: now.toISOString(),
|
|
194
|
+
...(c.ms !== undefined ? { ms: Math.max(0, Math.round(c.ms)) } : {}),
|
|
195
|
+
agent: c.agent,
|
|
196
|
+
job: scope.job || DEFAULT_JOB[c.agent] || "other",
|
|
197
|
+
...(scope.task || c.task ? { task: scope.task || c.task } : {}),
|
|
198
|
+
purpose: c.purpose || purposeOf(tools),
|
|
199
|
+
...(tools ? { tools } : {}),
|
|
200
|
+
...(scope.runId ? { runId: scope.runId } : {}),
|
|
201
|
+
...(scope.caseId ? { caseId: scope.caseId } : {}),
|
|
202
|
+
...(scope.attempt ? { attempt: scope.attempt } : {}),
|
|
203
|
+
...(c.step !== undefined ? { step: c.step } : {}),
|
|
204
|
+
...(c.session ? { session: c.session } : {}),
|
|
205
|
+
...(c.provider ? { provider: c.provider } : {}),
|
|
206
|
+
model: c.model || "unknown",
|
|
207
|
+
input: c.usage.input,
|
|
208
|
+
...(c.usage.cached ? { cached: c.usage.cached } : {}),
|
|
209
|
+
...(c.usage.cacheWrite ? { cacheWrite: c.usage.cacheWrite } : {}),
|
|
210
|
+
output: c.usage.output,
|
|
211
|
+
...(c.reasoning ? { reasoning: c.reasoning } : {}),
|
|
212
|
+
...(cost ? { amount: cost.amount, currency: cost.currency } : fallback ? { amount: fallback.amount, currency: fallback.currency, estimate: true } : { unpriced: true }),
|
|
213
|
+
...(cost && (cost.estimate || cost.cachedAtFullRate) ? { estimate: true } : {}),
|
|
214
|
+
outcome: c.outcome || "ok",
|
|
215
|
+
...who(),
|
|
216
|
+
trigger: triggerNow(),
|
|
217
|
+
proc: PROC,
|
|
218
|
+
}
|
|
219
|
+
calls.push(rec)
|
|
220
|
+
if (calls.length > MAX_KEPT) calls.splice(0, calls.length - MAX_KEPT)
|
|
221
|
+
if (keeping()) {
|
|
222
|
+
try {
|
|
223
|
+
mkdirSync(usageDir(), { recursive: true })
|
|
224
|
+
appendFileSync(monthFile(now), JSON.stringify(rec) + "\n")
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
for (const l of listeners) {
|
|
228
|
+
try {
|
|
229
|
+
l(rec)
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
return rec
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** This process's calls, filtered. */
|
|
236
|
+
export function sessionCalls(filter?: (c: CallRecord) => boolean): CallRecord[] {
|
|
237
|
+
return filter ? calls.filter(filter) : [...calls]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The calls of one run (a browser run's folder name), in this process. */
|
|
241
|
+
export function runCalls(runId: string): CallRecord[] {
|
|
242
|
+
return calls.filter((c) => c.runId === runId)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** usage.json in a run's folder: that run's calls, for its report. Returns the file, or null when it made none. */
|
|
246
|
+
export function writeRunUsage(dir: string): string | null {
|
|
247
|
+
const mine = runCalls(path.basename(dir))
|
|
248
|
+
if (!mine.length) return null
|
|
249
|
+
const file = path.join(dir, "usage.json")
|
|
250
|
+
writeFileSync(file, JSON.stringify({ v: 1, calls: mine }, null, 2))
|
|
251
|
+
return file
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Every call in the ledger files from `since` (inclusive) to `until` (exclusive), oldest first. Bad lines are skipped. */
|
|
255
|
+
export function readLedger(opts: { since?: Date; until?: Date; dir?: string } = {}): CallRecord[] {
|
|
256
|
+
const dir = opts.dir || usageDir()
|
|
257
|
+
if (!existsSync(dir)) return []
|
|
258
|
+
const since = opts.since?.getTime() ?? -Infinity
|
|
259
|
+
const until = opts.until?.getTime() ?? Infinity
|
|
260
|
+
const first = opts.since ? `${opts.since.getFullYear()}-${String(opts.since.getMonth() + 1).padStart(2, "0")}` : ""
|
|
261
|
+
const out: CallRecord[] = []
|
|
262
|
+
for (const f of readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f)).sort()) {
|
|
263
|
+
if (first && f.slice(0, 7) < first) continue
|
|
264
|
+
let text = ""
|
|
265
|
+
try {
|
|
266
|
+
text = readFileSync(path.join(dir, f), "utf8")
|
|
267
|
+
} catch {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
for (const line of text.split("\n")) {
|
|
271
|
+
if (!line.trim()) continue
|
|
272
|
+
try {
|
|
273
|
+
const c = JSON.parse(line) as CallRecord
|
|
274
|
+
const t = Date.parse(c.ts)
|
|
275
|
+
if (c.v === 1 && t >= since && t < until) out.push(c)
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out.sort((a, b) => a.ts.localeCompare(b.ts))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Tests: forget this process's calls and listeners. */
|
|
283
|
+
export function resetLedger(): void {
|
|
284
|
+
calls.length = 0
|
|
285
|
+
listeners.clear()
|
|
286
|
+
trigger = null
|
|
287
|
+
}
|
package/src/tui/App.tsx
CHANGED
|
@@ -17,7 +17,10 @@ import { HELP, parseCommand } from "../commands.ts"
|
|
|
17
17
|
import { loadConfig, selectProfile, type AppConfig, type BrowserProvider } from "../config.ts"
|
|
18
18
|
import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
|
|
19
19
|
import type { executeRun } from "../run.ts"
|
|
20
|
-
import { bundleLogs } from "../sessionLog.ts"
|
|
20
|
+
import { bundleLogs, logEvent } from "../sessionLog.ts"
|
|
21
|
+
import { openRegistry, registryMode, type Plan, type Registry } from "../registry/index.ts"
|
|
22
|
+
import { resolveRequest, resolveTarget } from "../registry/resolve.ts"
|
|
23
|
+
import { runChecks, tier0Reply, type RunSummary } from "../registry/tier0.ts"
|
|
21
24
|
import { findSkill, loadSkills } from "../skills/registry.ts"
|
|
22
25
|
import { skillDetail, skillsOverview } from "../skills/view.ts"
|
|
23
26
|
import { counts } from "../suite/report.ts"
|
|
@@ -45,6 +48,8 @@ import { CONTEXT_LIMIT } from "../code/runtime.ts"
|
|
|
45
48
|
import { readKnowledge } from "../knowledge.ts"
|
|
46
49
|
import { externalMcp } from "../mcp/external.ts"
|
|
47
50
|
import { meterSince } from "../usage.ts"
|
|
51
|
+
import { setUsageTrigger } from "../tokenomics/ledger.ts"
|
|
52
|
+
import { watchBudgets } from "../tokenomics/budget.ts"
|
|
48
53
|
import {
|
|
49
54
|
ACTIVITY_FILTERS,
|
|
50
55
|
ActivityPanel,
|
|
@@ -893,6 +898,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
893
898
|
failedChecks: r?.failedChecks,
|
|
894
899
|
quality: r?.quality,
|
|
895
900
|
budgetExceeded: r?.budgetExceeded,
|
|
901
|
+
api: r?.api,
|
|
896
902
|
ms: e.ms,
|
|
897
903
|
tokens: r ? r.usage.input + r.usage.output : 0,
|
|
898
904
|
steps: stepRef.current,
|
|
@@ -1161,6 +1167,81 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1161
1167
|
}
|
|
1162
1168
|
}
|
|
1163
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* Tier 0 (docs/COST_ARCHITECTURE.md): a passed run, run again as recorded through the same browser_run the agent
|
|
1172
|
+
* calls (an existing test first, then page memory), with no agent; the reply is written from the result.
|
|
1173
|
+
*/
|
|
1174
|
+
const runKnown = async (plan: Plan, cfg: AppConfig, reg: Registry, request: string) => {
|
|
1175
|
+
const controller = begin("Running it again as recorded…", request)
|
|
1176
|
+
testsRef.current = { on: sessionSetup(cfg, options.repo).writeTests, why: "the session was started with --no-code" }
|
|
1177
|
+
const t0 = Date.now()
|
|
1178
|
+
let finalStatus = "fail"
|
|
1179
|
+
let out: RunSummary = {}
|
|
1180
|
+
try {
|
|
1181
|
+
const task = Boolean(plan.lastRunDir && existsSync(path.join(plan.lastRunDir, "task.json")))
|
|
1182
|
+
const args = { goal: plan.goal, purpose: task ? "task" : "test", ...(plan.startUrl ? { start_url: plan.startUrl } : {}) }
|
|
1183
|
+
const raw = (await TOOLS[0].run(args, { ...toolContext(false, controller.signal), config: cfg, cwd: process.cwd() })) as RunSummary & { passed?: RunSummary }
|
|
1184
|
+
out = raw.passed || raw
|
|
1185
|
+
out = { ...out, ...runChecks(out.run_dir) }
|
|
1186
|
+
finalStatus = out.status === "passed" ? "done" : out.status === "stopped" ? "stopped" : "fail"
|
|
1187
|
+
const reply = tier0Reply(plan, out)
|
|
1188
|
+
pushLine({ kind: "reply", text: reply.text, outcome: reply.outcome })
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
finalStatus = controller.signal.aborted ? "stopped" : "fail"
|
|
1191
|
+
if (!controller.signal.aborted) push("err", `Running it again failed: ${(err as Error).message}`)
|
|
1192
|
+
} finally {
|
|
1193
|
+
try {
|
|
1194
|
+
reg.log({ request, mode: "on", decision: "tier0", planId: plan.id, tier: 0, outcome: out.status || finalStatus, tokensIn: out.tokens || 0, ms: Date.now() - t0, ...(out.run_dir ? { runDir: out.run_dir } : {}) })
|
|
1195
|
+
} catch {}
|
|
1196
|
+
reg.close()
|
|
1197
|
+
pushProduced()
|
|
1198
|
+
finish(finalStatus)
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* The registry's decision on a request (docs/COST_ARCHITECTURE.md). off: nothing, today's path. shadow: recorded
|
|
1204
|
+
* only, today's path. on: a Tier 0 match runs here and true is returned; anything else goes to the agent as before.
|
|
1205
|
+
* Any registry error also means today's path.
|
|
1206
|
+
*/
|
|
1207
|
+
const routeKnown = (text: string, cfg: AppConfig): boolean => {
|
|
1208
|
+
const mode = registryMode(cfg)
|
|
1209
|
+
if (mode === "off") return false
|
|
1210
|
+
const reg = openRegistry(cfg)
|
|
1211
|
+
if (!reg) return false
|
|
1212
|
+
try {
|
|
1213
|
+
const d = resolveRequest(reg, text)
|
|
1214
|
+
const decision = d.tier === 0 ? "tier0" : d.why
|
|
1215
|
+
logEvent("registry.decision", { mode, decision, plan: d.plan?.id })
|
|
1216
|
+
if (mode === "on" && d.tier === 0) {
|
|
1217
|
+
void runKnown(d.plan, cfg, reg, text)
|
|
1218
|
+
return true
|
|
1219
|
+
}
|
|
1220
|
+
reg.log({ request: text, mode: mode === "on" ? "on" : "shadow", decision, ...(d.plan ? { planId: d.plan.id } : {}), ...(d.tier === 0 ? { tier: 0 } : {}) })
|
|
1221
|
+
} catch {}
|
|
1222
|
+
reg.close()
|
|
1223
|
+
return false
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** /rerun: an explicit request to run a passed run again as recorded, whatever registry.mode says. */
|
|
1227
|
+
const rerunCommand = (target: string) => {
|
|
1228
|
+
const cfg = configRef.current
|
|
1229
|
+
if (!cfg) return push("err", "Config failed to load.")
|
|
1230
|
+
if (abortRef.current) return push("err", "ForgeQE is busy. Press esc to stop it.")
|
|
1231
|
+
const reg = openRegistry(cfg)
|
|
1232
|
+
if (!reg) return push("err", "The run registry could not be opened, so /rerun cannot look the run up. Ask ForgeQE to run it again instead.")
|
|
1233
|
+
let d: ReturnType<typeof resolveTarget>
|
|
1234
|
+
try {
|
|
1235
|
+
d = resolveTarget(reg, target, true)
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
reg.close()
|
|
1238
|
+
return push("err", `The run registry could not be read: ${(err as Error).message}. Ask ForgeQE to run it again instead.`)
|
|
1239
|
+
}
|
|
1240
|
+
if (d.tier === 0) return void runKnown(d.plan, cfg, reg, `/rerun ${target}`)
|
|
1241
|
+
reg.close()
|
|
1242
|
+
push("err", d.plan ? `Not run again as recorded: ${d.why} (${d.plan.goal}). Ask ForgeQE to look into it, or to run it again.` : `Nothing to run again: ${d.why} for "${target}". /rerun takes last, a run folder, or a goal word for word.`)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1164
1245
|
const askAgent = async (text: string, cfg: AppConfig) => {
|
|
1165
1246
|
const controller = begin("Thinking…", text)
|
|
1166
1247
|
const meterStart = meterSnapshot()
|
|
@@ -1231,7 +1312,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1231
1312
|
}
|
|
1232
1313
|
const why = options.brain ? null : brainUnavailable(cfg)
|
|
1233
1314
|
if (why) void runDirect(text, cfg, why)
|
|
1234
|
-
else void askAgent(text, cfg)
|
|
1315
|
+
else if (!routeKnown(text, cfg)) void askAgent(text, cfg)
|
|
1235
1316
|
}
|
|
1236
1317
|
|
|
1237
1318
|
/** Reads forgeqe.yaml and llms.json again (after /init wrote them), keeping the session going. */
|
|
@@ -1254,6 +1335,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1254
1335
|
let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
|
|
1255
1336
|
if (options.profile) cfg = selectProfile(cfg, options.profile)
|
|
1256
1337
|
configRef.current = cfg
|
|
1338
|
+
// Budgets only warn: each threshold once, in the conversation, while work goes on.
|
|
1339
|
+
setUsageTrigger("tui")
|
|
1340
|
+
if (cfg.budget) watchBudgets(cfg.budget, (w) => push("sys", w.text, "chat"))
|
|
1257
1341
|
// A price in llms.json that differs from the model's list price (probably a typo or an old price), said with the
|
|
1258
1342
|
// first message so the home screen stays as it is.
|
|
1259
1343
|
const reviewer = cfg.code.reviewProfile ? cfg.profiles[cfg.code.reviewProfile] : undefined
|
|
@@ -1409,6 +1493,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1409
1493
|
void runAuth(cmd.action, cmd.system).catch((err) => push("err", (err as Error).message))
|
|
1410
1494
|
return
|
|
1411
1495
|
}
|
|
1496
|
+
if (cmd.type === "rerun") {
|
|
1497
|
+
rerunCommand(cmd.target)
|
|
1498
|
+
return
|
|
1499
|
+
}
|
|
1412
1500
|
if (cmd.type === "unknown") {
|
|
1413
1501
|
push("err", "Usage: /headed on|off, /details on|off, /profile <name>, /auth [system], /auth logout <system>. /help lists the commands.")
|
|
1414
1502
|
return
|
package/src/tui/inspector.tsx
CHANGED
|
@@ -101,15 +101,7 @@ export function StatusBar(props: {
|
|
|
101
101
|
const done = props.status === "done"
|
|
102
102
|
const failed = props.status === "fail"
|
|
103
103
|
const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
|
|
104
|
-
const state = props.running
|
|
105
|
-
? props.who
|
|
106
|
-
: done
|
|
107
|
-
? "Last request done"
|
|
108
|
-
: failed
|
|
109
|
-
? "Last request failed"
|
|
110
|
-
: props.status === "stopped"
|
|
111
|
-
? "Last request stopped"
|
|
112
|
-
: "Ready for your next request"
|
|
104
|
+
const state = props.running ? props.who : done ? "Last request done" : failed ? "Last request failed" : props.status === "stopped" ? "Last request stopped" : "Ready for your next request"
|
|
113
105
|
const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
|
|
114
106
|
const cells = 12
|
|
115
107
|
const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
|
|
@@ -183,7 +175,13 @@ export function planLines(s: Inspector): PlanLine[] {
|
|
|
183
175
|
if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
|
|
184
176
|
for (const i of s.progress.items) {
|
|
185
177
|
const [mark, color] = PROGRESS_MARK[i.status]
|
|
186
|
-
lines.push({
|
|
178
|
+
lines.push({
|
|
179
|
+
mark,
|
|
180
|
+
color,
|
|
181
|
+
text: i.detail ? `${i.label} — ${i.detail}` : i.label,
|
|
182
|
+
textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted,
|
|
183
|
+
...(i.status === "running" ? { live: true } : {}),
|
|
184
|
+
})
|
|
187
185
|
}
|
|
188
186
|
return lines
|
|
189
187
|
}
|
|
@@ -352,17 +350,13 @@ export function nowNext(s: Inspector): { now?: string; next?: string; done: numb
|
|
|
352
350
|
const at = s.plan.findIndex((p) => p.status === "in_progress")
|
|
353
351
|
const step = [...s.progress.items].reverse().find((i) => i.status === "running")
|
|
354
352
|
const pending = s.plan.find((p, i) => p.status === "pending" && i > at)
|
|
355
|
-
return {
|
|
356
|
-
now: at >= 0 ? s.plan[at]!.text : step?.label,
|
|
357
|
-
next: pending?.text,
|
|
358
|
-
done: s.plan.filter((p) => p.status === "completed").length,
|
|
359
|
-
total: s.plan.length,
|
|
360
|
-
}
|
|
353
|
+
return { now: at >= 0 ? s.plan[at]!.text : step?.label, next: pending?.text, done: s.plan.filter((p) => p.status === "completed").length, total: s.plan.length }
|
|
361
354
|
}
|
|
362
355
|
|
|
363
|
-
/** Rows the Now/Next header takes: one for each it has. */
|
|
356
|
+
/** Rows the Now/Next header takes: one for each it has, and a blank row under it before the lines. */
|
|
364
357
|
export function nowNextRows(n: ReturnType<typeof nowNext>): number {
|
|
365
|
-
|
|
358
|
+
const rows = (n.now ? 1 : 0) + (n.next ? 1 : 0)
|
|
359
|
+
return rows ? rows + 1 : 0
|
|
366
360
|
}
|
|
367
361
|
|
|
368
362
|
/** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
|
|
@@ -402,22 +396,27 @@ export function ActivityPanel(props: {
|
|
|
402
396
|
})}
|
|
403
397
|
</box>
|
|
404
398
|
) : null}
|
|
405
|
-
{props.now?.now ? (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
<
|
|
410
|
-
|
|
411
|
-
|
|
399
|
+
{props.now?.now || props.now?.next ? (
|
|
400
|
+
// A blank row under Now/Next keeps the header apart from the lines scrolling beneath it.
|
|
401
|
+
<box style={{ flexDirection: "column", flexShrink: 0, marginBottom: 1 }}>
|
|
402
|
+
{props.now?.now ? (
|
|
403
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
|
|
404
|
+
<text fg={theme.muted}>{"Now "}</text>
|
|
405
|
+
<box style={{ width: 2, flexShrink: 0 }}>
|
|
406
|
+
<Spinner kind="dots" color={theme.warn} />
|
|
407
|
+
</box>
|
|
408
|
+
<text fg={theme.text}>{clip(props.now.now, room)}</text>
|
|
409
|
+
</box>
|
|
410
|
+
) : null}
|
|
411
|
+
{props.now?.next ? (
|
|
412
|
+
<text style={{ height: 1, flexShrink: 0 }}>
|
|
413
|
+
<span fg={theme.muted}>{"Next "}</span>
|
|
414
|
+
<span fg={theme.faint}>{"○ "}</span>
|
|
415
|
+
<span fg={theme.muted}>{clip(props.now.next, room)}</span>
|
|
416
|
+
</text>
|
|
417
|
+
) : null}
|
|
412
418
|
</box>
|
|
413
419
|
) : null}
|
|
414
|
-
{props.now?.next ? (
|
|
415
|
-
<text style={{ height: 1, flexShrink: 0 }}>
|
|
416
|
-
<span fg={theme.muted}>{"Next "}</span>
|
|
417
|
-
<span fg={theme.faint}>{"○ "}</span>
|
|
418
|
-
<span fg={theme.muted}>{clip(props.now.next, room)}</span>
|
|
419
|
-
</text>
|
|
420
|
-
) : null}
|
|
421
420
|
{real(shown) ? (
|
|
422
421
|
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
423
422
|
{shown.map((line, i) => (
|
|
@@ -550,9 +549,15 @@ export function InfoStrip(props: { s: Inspector; c: ContextInfo; total: Usage; w
|
|
|
550
549
|
<box style={{ flexDirection: "row", height: 1, flexShrink: 0, width: props.width, paddingLeft: 1 }}>
|
|
551
550
|
<text>
|
|
552
551
|
{parts.flatMap(([label, value, color], i) => [
|
|
553
|
-
<span key={`s${i}`} fg={theme.faint}>
|
|
554
|
-
|
|
555
|
-
|
|
552
|
+
<span key={`s${i}`} fg={theme.faint}>
|
|
553
|
+
{i ? " · " : ""}
|
|
554
|
+
</span>,
|
|
555
|
+
<span key={`l${i}`} fg={theme.muted}>
|
|
556
|
+
{label}
|
|
557
|
+
</span>,
|
|
558
|
+
<span key={`v${i}`} fg={color}>
|
|
559
|
+
{value}
|
|
560
|
+
</span>,
|
|
556
561
|
])}
|
|
557
562
|
</text>
|
|
558
563
|
</box>
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { createContext, useContext, type ReactNode } from "react"
|
|
|
6
6
|
|
|
7
7
|
import type { CodeResult, RunStatus } from "../types.ts"
|
|
8
8
|
import { CARD_MARK, type Card } from "../card.ts"
|
|
9
|
-
import { counts } from "../suite/report.ts"
|
|
9
|
+
import { counts, suiteHeadline } from "../suite/report.ts"
|
|
10
10
|
import { suiteExitCode, type SuiteResult } from "../suite/run.ts"
|
|
11
11
|
import { STATUS_MARK, shortPath, type RunSummary } from "../summary.ts"
|
|
12
12
|
import { BAR } from "./components.tsx"
|
|
@@ -192,6 +192,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
192
192
|
{summary.finalUrl ? detail("Ended on", <span fg={theme.muted}>{summary.finalUrl}</span>) : null}
|
|
193
193
|
{summary.memory ? detail("Memory", <span fg={theme.muted}>{summary.memory}</span>) : null}
|
|
194
194
|
{summary.quality ? detail("Quality", <span fg={theme.muted}>{summary.quality}</span>) : null}
|
|
195
|
+
{summary.api ? detail("API", <Linked text={summary.api} color={theme.muted} lead={UNDER + 10} />) : null}
|
|
195
196
|
{summary.savedDir ? (
|
|
196
197
|
<text>
|
|
197
198
|
<span fg={theme.faint}>{"Saved "}</span>
|
|
@@ -272,7 +273,7 @@ export function Row({ label, value, color = theme.muted }: { label: string; valu
|
|
|
272
273
|
export const CASE_MARK = { passed: "✓", failed: "✗", error: "!", stopped: "■" } as const
|
|
273
274
|
|
|
274
275
|
export function SuiteCard({ suite }: { suite: SuiteResult }) {
|
|
275
|
-
const
|
|
276
|
+
const head = suiteHeadline(suite)
|
|
276
277
|
const ok = !suite.stopped && suiteExitCode(suite) === 0
|
|
277
278
|
const color = suite.stopped ? theme.warn : ok ? theme.ok : theme.err
|
|
278
279
|
const tokens = suite.results.reduce((n, r) => n + r.tokens, 0)
|
|
@@ -283,9 +284,9 @@ export function SuiteCard({ suite }: { suite: SuiteResult }) {
|
|
|
283
284
|
<Marked mark={MARK.item} color={color} gap>
|
|
284
285
|
<text>
|
|
285
286
|
<span fg={theme.text}>
|
|
286
|
-
<strong>{
|
|
287
|
+
<strong>{head.title}</strong>
|
|
287
288
|
</span>
|
|
288
|
-
<span fg={theme.faint}>{` ${
|
|
289
|
+
<span fg={theme.faint}>{` ${head.counts} · ${formatDuration(suite.ms)} · ${formatTokens(tokens)} tokens`}</span>
|
|
289
290
|
</text>
|
|
290
291
|
</Marked>
|
|
291
292
|
<Under first>
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,11 @@ export type MemoryInfo = {
|
|
|
29
29
|
elements: number
|
|
30
30
|
newElements: number
|
|
31
31
|
flow: "saved" | "updated" | null
|
|
32
|
+
/** Recorded checks found on the page again during the replay, and how many of them held (registry.mode on). */
|
|
33
|
+
checked?: number
|
|
34
|
+
checksHeld?: number
|
|
35
|
+
/** Read-only steps the replay left out. */
|
|
36
|
+
skippedReads?: number
|
|
32
37
|
/** After a failed run: the steps that worked before it went wrong, saved in resume.json for a retry to replay. */
|
|
33
38
|
resume?: { steps: number; failedAt: number; reason: string; lines: string[]; file?: string }
|
|
34
39
|
}
|
|
@@ -59,6 +64,8 @@ export type RunResult = {
|
|
|
59
64
|
quality?: QualityReport
|
|
60
65
|
/** Budgets this run exceeded: the run counts as failed, though its steps passed. */
|
|
61
66
|
budgetExceeded?: string[]
|
|
67
|
+
/** The run's API steps, saved as a reusable API scenario: where, and how many calls. */
|
|
68
|
+
api?: { id: string; dir: string; calls: number }
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/** Why a failed step failed, in the browser agent's judgement: see src/cases/failure.ts. */
|
|
@@ -116,8 +123,10 @@ export type CodeResult = {
|
|
|
116
123
|
knownDefect?: boolean
|
|
117
124
|
/** The job did not finish: what it wrote was moved to the needs-attention folder (see src/code/attention.ts). */
|
|
118
125
|
attention?: { dir: string; files: string[]; patch?: string; readme: string }
|
|
119
|
-
/** The job worked from an existing test: ran it as it was, extended it, or
|
|
120
|
-
existing?: "reused" | "extended" | "updated"
|
|
126
|
+
/** The job worked from an existing test: ran it as it was, extended it, updated it, or added a test beside it. */
|
|
127
|
+
existing?: "reused" | "extended" | "updated" | "added_beside"
|
|
128
|
+
/** Times Scribe changed the existing test it was asked to keep, and was sent back before the test ran. */
|
|
129
|
+
breaches?: number
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
/** What an agent did to a file, for the shell's file list. */
|