@capagents/forgeqe 1.3.1 → 2.0.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 (119) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/cli.py +10 -0
  37. package/py/forgeqe_engine/llm.py +54 -4
  38. package/py/forgeqe_engine/model_params.py +169 -0
  39. package/py/forgeqe_engine/summary.py +6 -0
  40. package/py/forgeqe_mobile/__init__.py +1 -0
  41. package/py/forgeqe_mobile/__main__.py +3 -0
  42. package/py/forgeqe_mobile/appium_driver.py +342 -0
  43. package/py/forgeqe_mobile/apps.py +84 -0
  44. package/py/forgeqe_mobile/cli.py +70 -0
  45. package/py/forgeqe_mobile/explore.py +203 -0
  46. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  47. package/py/forgeqe_mobile/maestro.py +321 -0
  48. package/py/forgeqe_mobile/service.py +332 -0
  49. package/skills/testcontainers/SKILL.md +42 -0
  50. package/src/agents.ts +11 -2
  51. package/src/api/plan.ts +2 -0
  52. package/src/api/write.ts +2 -0
  53. package/src/auth/copilot.ts +130 -0
  54. package/src/auth/signin.ts +54 -1
  55. package/src/brain/index.ts +26 -4
  56. package/src/capabilities/guide.ts +2 -0
  57. package/src/capabilities/registry.ts +110 -2
  58. package/src/capabilities/status.ts +15 -0
  59. package/src/cases/defects.ts +2 -1
  60. package/src/cases/rca.ts +2 -0
  61. package/src/childEnv.ts +4 -1
  62. package/src/cli/args.ts +3 -2
  63. package/src/cli/help.ts +24 -3
  64. package/src/cli/index.ts +47 -3
  65. package/src/code/find.ts +26 -0
  66. package/src/code/loop.ts +4 -1
  67. package/src/code/prompt.ts +2 -1
  68. package/src/code/prreview.ts +2 -0
  69. package/src/code/review.ts +6 -2
  70. package/src/code/runtime.ts +34 -3
  71. package/src/commands.ts +6 -1
  72. package/src/config.ts +159 -3
  73. package/src/containers/command.ts +68 -0
  74. package/src/containers/compose.ts +173 -0
  75. package/src/containers/config.ts +283 -0
  76. package/src/containers/mock.ts +156 -0
  77. package/src/containers/run.ts +362 -0
  78. package/src/containers/tests.ts +80 -0
  79. package/src/context.ts +33 -6
  80. package/src/copilot.ts +155 -0
  81. package/src/desktop/bridge.ts +229 -0
  82. package/src/desktop/command.ts +350 -0
  83. package/src/desktop/edit.ts +217 -0
  84. package/src/desktop/junit.ts +35 -0
  85. package/src/desktop/report.ts +155 -0
  86. package/src/desktop/run.ts +252 -0
  87. package/src/desktop/setup.ts +134 -0
  88. package/src/engine/bridge.ts +12 -5
  89. package/src/engine/setup.ts +6 -3
  90. package/src/environments.ts +566 -0
  91. package/src/evals/domain.ts +1 -0
  92. package/src/explore/charters.ts +2 -0
  93. package/src/guardrails.ts +145 -0
  94. package/src/heal/loop.ts +4 -1
  95. package/src/heal/prompt.ts +2 -1
  96. package/src/init.ts +5 -2
  97. package/src/llm.ts +23 -2
  98. package/src/mcp/attachtools.ts +16 -4
  99. package/src/mcp/captools.ts +27 -0
  100. package/src/mcp/containertools.ts +212 -0
  101. package/src/mcp/desktoptools.ts +334 -0
  102. package/src/mcp/mobiletools.ts +132 -0
  103. package/src/mcp/reporttools.ts +17 -2
  104. package/src/mcp/repotests.ts +22 -3
  105. package/src/mcp/routing.ts +11 -5
  106. package/src/mcp/scope.ts +210 -0
  107. package/src/mcp/server.ts +80 -5
  108. package/src/mcp/tools.ts +38 -1
  109. package/src/mobile/command.ts +245 -0
  110. package/src/mobile/run.ts +226 -0
  111. package/src/pack/pipeline.ts +44 -2
  112. package/src/refine.ts +3 -0
  113. package/src/report/html.ts +3 -0
  114. package/src/report/template.html +48 -48
  115. package/src/run.ts +7 -3
  116. package/src/tokenomics/ledger.ts +3 -1
  117. package/src/tui/App.tsx +23 -0
  118. package/src/update.ts +118 -8
  119. package/src/usage.ts +1 -1
@@ -0,0 +1,155 @@
1
+ import { writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { DesktopResult, DesktopStep } from "./bridge.ts"
5
+
6
+ /**
7
+ * report.html for a desktop run: every value from run.json is escaped, since step errors, control names and goals come
8
+ * from the app under test and the model. Images are only the engine's own files: step screenshots (steps/NNN.png) and
9
+ * expect_image's copies (images/NNN-baseline|actual|diff.png).
10
+ */
11
+
12
+ export function escapeHtml(value: unknown): string {
13
+ return String(value ?? "")
14
+ .replace(/&/g, "&")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#39;")
19
+ .replace(/\u2028/g, "&#8232;")
20
+ .replace(/\u2029/g, "&#8233;")
21
+ }
22
+
23
+ const SCREENSHOT = /^steps\/\d{3}\.png$/
24
+ const STEP_IMAGE = /^images\/\d{3}-(baseline|actual|diff)\.png$/
25
+
26
+ /** A step screenshot's relative path, or null for anything that is not one the engine wrote. */
27
+ export function screenshotPath(value: unknown): string | null {
28
+ return typeof value === "string" && SCREENSHOT.test(value) ? value : null
29
+ }
30
+
31
+ /** An expect_image file's relative path (images/NNN-baseline|actual|diff.png), or null. */
32
+ export function stepImagePath(value: unknown): string | null {
33
+ return typeof value === "string" && STEP_IMAGE.test(value) ? value : null
34
+ }
35
+
36
+ function imageCell(s: DesktopStep): string {
37
+ const im = s.images
38
+ if (!im) return ""
39
+ const pics = (["baseline", "actual", "diff"] as const)
40
+ .map((k) => {
41
+ const src = stepImagePath(im[k])
42
+ return src ? `<figure><a href="${src}"><img src="${src}" alt="${k} of step ${escapeHtml(s.index)}" loading="lazy"></a><figcaption>${k}</figcaption></figure>` : ""
43
+ })
44
+ .join("")
45
+ const share = typeof im.differs === "number" ? `<div class="meta">${escapeHtml((im.differs * 100).toFixed(2))}% of pixels differ</div>` : ""
46
+ return `<div class="images">${pics}</div>${share}`
47
+ }
48
+
49
+ function target(t: Record<string, unknown> | null | undefined): string {
50
+ if (!t) return ""
51
+ return Object.entries(t)
52
+ .filter(([k]) => k !== "path")
53
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
54
+ .join(", ")
55
+ }
56
+
57
+ function stepRow(s: DesktopStep): string {
58
+ const shot = screenshotPath(s.screenshot)
59
+ const healed = s.healed_target ? `<div class="heal">healed to ${escapeHtml(target(s.healed_target))}</div>` : ""
60
+ const tries = typeof s.attempts === "number" && s.attempts > 1 ? `<div class="heal">took ${escapeHtml(s.attempts)} tries</div>` : ""
61
+ const dismissed = (s.dismissed || []).length ? `<div class="heal">closed in the way: ${escapeHtml((s.dismissed || []).join(", "))}</div>` : ""
62
+ const note = s.note ? `<div class="meta">${escapeHtml(s.note)}</div>` : ""
63
+ return `<tr class="${s.ok ? "ok" : "fail"}">
64
+ <td class="n">${escapeHtml(s.index)}</td>
65
+ <td><span class="badge ${s.ok ? "ok" : "fail"}">${s.ok ? "passed" : "failed"}</span></td>
66
+ <td><code>${escapeHtml(s.op)}</code></td>
67
+ <td>${escapeHtml(target(s.target))}${healed}${tries}${dismissed}${note}${s.error ? `<div class="err">${escapeHtml(s.error)}</div>` : ""}${imageCell(s)}</td>
68
+ <td class="n">${escapeHtml(s.ms)} ms</td>
69
+ <td>${shot ? `<a href="${shot}"><img src="${shot}" alt="Screen after step ${escapeHtml(s.index)}" loading="lazy"></a>` : ""}</td>
70
+ </tr>`
71
+ }
72
+
73
+ function flowsSection(r: DesktopResult): string {
74
+ if (!(r.flows || []).length) return ""
75
+ const rows = (r.flows || [])
76
+ .map((f) => `<tr class="${f.ok ? "ok" : "fail"}"><td><span class="badge ${f.ok ? "ok" : "fail"}">${f.ok ? "passed" : "failed"}</span></td><td>${escapeHtml(f.name)}${f.error ? `<div class="err">${escapeHtml(f.error)}</div>` : ""}</td></tr>`)
77
+ .join("")
78
+ return `<section><h2>Flows</h2><div class="table"><table><thead><tr><th>Result</th><th>Flow</th></tr></thead><tbody>${rows}</tbody></table></div></section>`
79
+ }
80
+
81
+ export function desktopReportHtml(r: DesktopResult, meta: { model: string; when: string; environment?: string; kind?: "Desktop" | "Mobile" }): string {
82
+ const kind = meta.kind || "Desktop"
83
+ const title = `${kind} ${r.mode === "explore" ? "explore" : r.mode === "record" ? "recording" : r.mode === "maestro" ? "Maestro flows" : "replay"}`
84
+ const status = r.ok ? "passed" : r.stopped ? "stopped" : "failed"
85
+ const summary =
86
+ r.mode === "maestro"
87
+ ? `${escapeHtml((r.flows || []).filter((f) => f.ok).length)} of ${escapeHtml((r.flows || []).length)} flows passed`
88
+ : r.mode === "record"
89
+ ? `${escapeHtml(r.recorded ?? 0)} steps recorded by hand, ${escapeHtml(r.checks ?? 0)} checks${r.secrets?.length ? `, types the secrets ${escapeHtml(r.secrets.join(", "))}` : ""}`
90
+ : r.mode === "explore"
91
+ ? `${escapeHtml(r.recorded ?? 0)} steps recorded, ${escapeHtml(r.checks ?? 0)} checks of the outcome${r.ufo_complete === false ? ", UFO judged the goal not done" : ""}`
92
+ : `${escapeHtml((r.steps || []).filter((s) => s.ok).length)} of ${escapeHtml(r.total ?? (r.steps || []).length)} steps passed`
93
+ const rows = (r.steps || []).map(stepRow).join("\n")
94
+ const heals = (r.heals || []).length
95
+ ? `<section><h2>Healed locators</h2><p>Proposed for review; the workflow file is unchanged until you accept them.</p><ul>${(r.heals || [])
96
+ .map((h) => `<li>Step ${escapeHtml(h.index)}: ${escapeHtml(target(h.old))} → ${escapeHtml(target(h.new))}</li>`)
97
+ .join("")}</ul></section>`
98
+ : ""
99
+ const skipped = (r.skipped || []).length
100
+ ? `<section><h2>Actions not recorded</h2><ul>${(r.skipped || []).map((s) => `<li>${escapeHtml(s.action)}: ${escapeHtml(s.reason)}</li>`).join("")}</ul></section>`
101
+ : ""
102
+ const refused = (r.refused || []).length
103
+ ? `<section><h2>Refused</h2><p>Actions ForgeQE did not let UFO take.</p><ul>${(r.refused || []).map((s) => `<li>${escapeHtml(s.action)} ${escapeHtml(JSON.stringify(s.args || {}))}</li>`).join("")}</ul></section>`
104
+ : ""
105
+ return `<!doctype html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="utf-8">
109
+ <meta name="viewport" content="width=device-width, initial-scale=1">
110
+ <title>${escapeHtml(title)}: ${escapeHtml(r.name || r.goal || "")}</title>
111
+ <style>
112
+ :root { --bg: #f4f6f9; --surface: #ffffff; --line: #e3e8ee; --text: #1f2a44; --muted: #5f6b80; --brand: #0074bf;
113
+ --pass: #1b883c; --pass-bg: #e8f5ec; --fail: #c62828; --fail-bg: #fcecec; --warn: #c45c12; --radius: 14px; }
114
+ @media (prefers-color-scheme: dark) { :root { --bg: #0f1424; --surface: #171e33; --line: #2a3350; --text: #e6eaf2; --muted: #9aa5bb;
115
+ --brand: #5bb6f0; --pass: #5fd07f; --pass-bg: #163322; --fail: #ff7b7b; --fail-bg: #3a1a1d; --warn: #f0a35e; } }
116
+ * { box-sizing: border-box; }
117
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
118
+ main { max-width: 1100px; margin: 0 auto; padding: 24px 16px; }
119
+ header, section { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 16px; }
120
+ h1 { font-size: 20px; margin: 0 0 4px; } h2 { font-size: 16px; margin: 0 0 8px; }
121
+ .meta { color: var(--muted); }
122
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
123
+ .badge.ok { color: var(--pass); background: var(--pass-bg); } .badge.fail { color: var(--fail); background: var(--fail-bg); }
124
+ .table { overflow-x: auto; }
125
+ table { width: 100%; border-collapse: collapse; }
126
+ th, td { text-align: left; vertical-align: top; padding: 8px; border-top: 1px solid var(--line); }
127
+ th { color: var(--muted); font-weight: 600; border-top: 0; }
128
+ td.n { white-space: nowrap; color: var(--muted); }
129
+ .err { color: var(--fail); margin-top: 4px; white-space: pre-wrap; } .heal { color: var(--warn); margin-top: 4px; }
130
+ img { max-width: 220px; border: 1px solid var(--line); border-radius: 6px; }
131
+ .images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; } figure { margin: 0; } figcaption { color: var(--muted); font-size: 12px; }
132
+ code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <main>
137
+ <header>
138
+ <h1>${escapeHtml(title)}: ${escapeHtml(r.name || r.goal || "")} <span class="badge ${r.ok ? "ok" : "fail"}">${status}</span></h1>
139
+ <div class="meta">${summary} · app ${escapeHtml(r.app || "")}${meta.environment ? ` · environment ${escapeHtml(meta.environment)}` : ""} · model ${escapeHtml(meta.model)} · ${escapeHtml(meta.when)}</div>
140
+ ${r.error ? `<p class="err">${escapeHtml(r.error)}</p>` : ""}
141
+ ${r.workflow ? `<p class="meta">Workflow: <code>${escapeHtml(r.workflow)}</code>${r.driver ? ` · driver ${escapeHtml(r.driver)}` : ""}</p>` : ""}
142
+ </header>
143
+ ${rows ? `<section><h2>Steps</h2><div class="table"><table><thead><tr><th>#</th><th>Result</th><th>Op</th><th>Control</th><th>Time</th><th>Screen</th></tr></thead><tbody>${rows}</tbody></table></div></section>` : ""}
144
+ ${flowsSection(r)}${heals}${skipped}${refused}
145
+ </main>
146
+ </body>
147
+ </html>
148
+ `
149
+ }
150
+
151
+ export function writeDesktopReport(outDir: string, r: DesktopResult, meta: { model: string; when?: string; environment?: string; kind?: "Desktop" | "Mobile" }): string {
152
+ const file = path.join(outDir, "report.html")
153
+ writeFileSync(file, desktopReportHtml(r, { model: meta.model, when: meta.when || new Date().toISOString(), ...(meta.environment ? { environment: meta.environment } : {}), ...(meta.kind ? { kind: meta.kind } : {}) }))
154
+ return file
155
+ }
@@ -0,0 +1,252 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import YAML from "yaml"
5
+
6
+ import { privateEnvNames } from "../childEnv.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import type { LlmProfile } from "../llm.ts"
9
+ import { runDesktop, type DesktopEvent, type DesktopRequest, type DesktopResult } from "./bridge.ts"
10
+ import { writeDesktopReport } from "./report.ts"
11
+ import { ensureDesktop, ufoDir } from "./setup.ts"
12
+
13
+ /** Explore and replay as the CLI (`forgeqe desktop`) and ForgeQE's agent (desktop_* tools) both run them. */
14
+
15
+ const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
16
+ const VAR = /^([A-Za-z_][A-Za-z0-9_]{0,63})=(.*)$/s
17
+ const MAX_VALUE = 10_000
18
+ export const MAX_DATA_ROWS = 1000
19
+
20
+ /** `name=value` words as workflow variables. */
21
+ export function parseVariables(words: string[]): Record<string, string> {
22
+ const out: Record<string, string> = {}
23
+ for (const w of words) {
24
+ const m = VAR.exec(w)
25
+ if (!m) throw new Error(`"${w}" is not name=value; variables are letters, digits and _, e.g. customer=Contoso`)
26
+ out[m[1]!] = m[2]!
27
+ }
28
+ return out
29
+ }
30
+
31
+ /** Variables as a tool's caller gives them: names checked, values text, nothing else. */
32
+ export function checkVariables(raw: unknown): Record<string, string> {
33
+ if (raw === undefined || raw === null) return {}
34
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("variables must be an object of name to text")
35
+ const out: Record<string, string> = {}
36
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
37
+ if (!VAR_NAME.test(k)) throw new Error(`variable "${k}": names are letters, digits and _`)
38
+ if (typeof v !== "string" && typeof v !== "number") throw new Error(`variable ${k} must be text`)
39
+ const value = String(v)
40
+ if (value.length > MAX_VALUE) throw new Error(`variable ${k} is longer than ${MAX_VALUE} characters`)
41
+ out[k] = value
42
+ }
43
+ return out
44
+ }
45
+
46
+ /** A CSV file's rows as variables, one object per row, named by the header row. */
47
+ export function parseCsv(text: string): Record<string, string>[] {
48
+ const rows: string[][] = []
49
+ let row: string[] = []
50
+ let field = ""
51
+ let quoted = false
52
+ const src = text.replace(/^/, "")
53
+ for (let i = 0; i < src.length; i++) {
54
+ const c = src[i]!
55
+ if (quoted) {
56
+ if (c === '"' && src[i + 1] === '"') {
57
+ field += '"'
58
+ i++
59
+ } else if (c === '"') quoted = false
60
+ else field += c
61
+ continue
62
+ }
63
+ if (c === '"' && field === "") quoted = true
64
+ else if (c === ",") {
65
+ row.push(field)
66
+ field = ""
67
+ } else if (c === "\n" || c === "\r") {
68
+ if (c === "\r" && src[i + 1] === "\n") i++
69
+ row.push(field)
70
+ field = ""
71
+ if (row.some((f) => f !== "")) rows.push(row)
72
+ row = []
73
+ } else field += c
74
+ }
75
+ if (quoted) throw new Error("the CSV file has an unclosed quote")
76
+ row.push(field)
77
+ if (row.some((f) => f !== "")) rows.push(row)
78
+ const [header, ...data] = rows
79
+ if (!header) throw new Error("the CSV file is empty")
80
+ const names = header.map((h) => h.trim())
81
+ for (const n of names) if (!VAR_NAME.test(n)) throw new Error(`CSV column "${n}": variable names are letters, digits and _`)
82
+ if (new Set(names).size !== names.length) throw new Error("the CSV header repeats a column")
83
+ if (data.length > MAX_DATA_ROWS) throw new Error(`the CSV file has ${data.length} rows; at most ${MAX_DATA_ROWS} run in one go`)
84
+ return data.map((r, i) => {
85
+ if (r.length !== names.length) throw new Error(`CSV row ${i + 2} has ${r.length} values; the header has ${names.length}`)
86
+ return Object.fromEntries(names.map((n, j) => [n, r[j]!]))
87
+ })
88
+ }
89
+
90
+ /** desktop.secrets that are ForgeQE's own (a model key, a tracker token): a workflow may never type those. */
91
+ export function privateDesktopSecrets(cfg: AppConfig): string[] {
92
+ const own = new Set(privateEnvNames(cfg))
93
+ return cfg.desktop.secrets.filter((n) => own.has(n))
94
+ }
95
+
96
+ /** Why a profile cannot drive explore (it sends screenshots), or null. */
97
+ export function visionProblem(name: string, p: LlmProfile): string | null {
98
+ const takes = typeof p.vision === "boolean" ? p.vision : p.provider !== "ollama"
99
+ return takes ? null : `llms.json profile "${name}" does not take images, and the desktop agent sends screenshots. Pick a vision model, or set "vision": true on the profile if it does take them.`
100
+ }
101
+
102
+ export function hasModel(cfg: AppConfig): boolean {
103
+ return Boolean(cfg.llm.model) && cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
104
+ }
105
+
106
+ /** Throws when the config cannot run desktop work: ForgeQE's own keys in desktop.secrets, or no model when one is needed. */
107
+ export function checkDesktopConfig(cfg: AppConfig, needsModel: boolean): void {
108
+ const own = privateDesktopSecrets(cfg)
109
+ if (own.length) throw new Error(`desktop.secrets lists ${own.join(", ")}, which ForgeQE itself uses (a model key or a service token); a workflow may not type it.`)
110
+ if (needsModel && !hasModel(cfg)) throw new Error("No model is set up in llms.json. Run: forgeqe init, then forgeqe models (or replay with --no-heal).")
111
+ }
112
+
113
+ export function checkApp(cfg: AppConfig, app: string): void {
114
+ if (!Object.prototype.hasOwnProperty.call(cfg.desktop.apps, app)) {
115
+ throw new Error(`App "${app}" is not in forgeqe.yaml desktop.apps (configured: ${Object.keys(cfg.desktop.apps).join(", ") || "none"}).`)
116
+ }
117
+ }
118
+
119
+ function stamp(): string {
120
+ return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19)
121
+ }
122
+
123
+ export function desktopOutDir(cfg: AppConfig, kind: "explore" | "replay" | "record", out?: string): string {
124
+ const dir = out ? path.resolve(out) : path.join(cfg.export.outDir, `desktop-${kind}-${stamp()}`)
125
+ mkdirSync(dir, { recursive: true })
126
+ return dir
127
+ }
128
+
129
+ /**
130
+ * A workflow file a caller names, as its real path: a .yaml or .yml file inside one of `roots` (the working folder, the
131
+ * reports folder). A link that leads outside them is refused like a path that does.
132
+ */
133
+ export function allowedWorkflow(file: string, roots: string[]): string {
134
+ if (!/\.ya?ml$/i.test(file)) throw new Error(`${file} is not a workflow (.yaml or .yml)`)
135
+ const resolved = path.resolve(roots[0] || process.cwd(), file)
136
+ if (!existsSync(resolved)) throw new Error(`No workflow at ${resolved}`)
137
+ const real = realpathSync(resolved)
138
+ for (const root of roots) {
139
+ if (!existsSync(root)) continue
140
+ const rel = path.relative(realpathSync(root), real)
141
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return real
142
+ }
143
+ throw new Error(`${file} is outside the working folder and the reports folder; workflows are read from those only.`)
144
+ }
145
+
146
+ /** Write accepted heals into the workflow, keeping its comments and layout. */
147
+ export function applyHeals(file: string, heals: NonNullable<DesktopResult["heals"]>): void {
148
+ const doc = YAML.parseDocument(readFileSync(file, "utf8"))
149
+ for (const h of heals) doc.setIn(["steps", h.index - 1, "target"], doc.createNode(h.new))
150
+ writeFileSync(file, doc.toString())
151
+ }
152
+
153
+ export type DesktopRun = { result: DesktopResult; report: string; outDir: string }
154
+
155
+ function modelLabel(cfg: AppConfig): string {
156
+ return `${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model})`
157
+ }
158
+
159
+ async function finish(cfg: AppConfig, outDir: string, result: DesktopResult): Promise<DesktopRun> {
160
+ const report = writeDesktopReport(outDir, result, { model: modelLabel(cfg), ...(cfg.environments?.active ? { environment: cfg.environments.active } : {}) })
161
+ if (result.heals?.length) writeFileSync(path.join(outDir, "heals.json"), JSON.stringify(result.heals, null, 2))
162
+ return { result, report, outDir }
163
+ }
164
+
165
+ /** Replay one workflow; heals are returned and saved to heals.json, never written into the workflow here. */
166
+ export async function replayWorkflow(opts: {
167
+ cfg: AppConfig
168
+ workflow: string
169
+ variables?: Record<string, string>
170
+ heal: boolean
171
+ updateBaselines?: boolean
172
+ outDir: string
173
+ signal?: AbortSignal
174
+ onEvent?: (e: DesktopEvent) => void
175
+ log?: (text: string) => void
176
+ }): Promise<DesktopRun> {
177
+ const python = await ensureDesktop(opts.log || (() => {}))
178
+ const request: DesktopRequest = {
179
+ mode: "replay",
180
+ outDir: opts.outDir,
181
+ llm: opts.cfg.llm,
182
+ profileName: opts.cfg.activeProfile,
183
+ desktop: opts.cfg.desktop,
184
+ workflow: opts.workflow,
185
+ variables: opts.variables || {},
186
+ heal: opts.heal && hasModel(opts.cfg),
187
+ ...(opts.updateBaselines ? { updateBaselines: true } : {}),
188
+ }
189
+ const result = await runDesktop({ python, request, signal: opts.signal, onEvent: opts.onEvent })
190
+ return finish(opts.cfg, opts.outDir, result)
191
+ }
192
+
193
+ /** Explore a goal with UFO² and save what it did, with checks of the outcome, as workflow.yaml. */
194
+ export async function exploreGoal(opts: {
195
+ cfg: AppConfig
196
+ goal: string
197
+ app: string
198
+ name?: string
199
+ outDir: string
200
+ signal?: AbortSignal
201
+ onEvent?: (e: DesktopEvent) => void
202
+ log?: (text: string) => void
203
+ }): Promise<DesktopRun> {
204
+ const python = await ensureDesktop(opts.log || (() => {}), { ufo: true })
205
+ const request: DesktopRequest = {
206
+ mode: "explore",
207
+ outDir: opts.outDir,
208
+ llm: opts.cfg.llm,
209
+ profileName: opts.cfg.activeProfile,
210
+ desktop: opts.cfg.desktop,
211
+ ufoDir: ufoDir(),
212
+ goal: opts.goal,
213
+ app: opts.app,
214
+ name: opts.name,
215
+ }
216
+ const result = await runDesktop({ python, request, signal: opts.signal, onEvent: opts.onEvent })
217
+ return finish(opts.cfg, opts.outDir, result)
218
+ }
219
+
220
+ /** Record by hand: start the app, and save what the tester does in it (until the hotkey, or the app closes) as workflow.yaml. */
221
+ export async function recordByHand(opts: {
222
+ cfg: AppConfig
223
+ app: string
224
+ name?: string
225
+ outDir: string
226
+ signal?: AbortSignal
227
+ onEvent?: (e: DesktopEvent) => void
228
+ log?: (text: string) => void
229
+ }): Promise<DesktopRun> {
230
+ const python = await ensureDesktop(opts.log || (() => {}))
231
+ const request: DesktopRequest = {
232
+ mode: "record",
233
+ outDir: opts.outDir,
234
+ llm: opts.cfg.llm,
235
+ profileName: opts.cfg.activeProfile,
236
+ desktop: opts.cfg.desktop,
237
+ app: opts.app,
238
+ ...(opts.name ? { name: opts.name } : {}),
239
+ }
240
+ // A stop ends the recording; the engine keeps what was recorded, so it gets time to save it.
241
+ const result = await runDesktop({ python, request, signal: opts.signal, onEvent: opts.onEvent, graceMs: 60_000 })
242
+ return finish(opts.cfg, opts.outDir, result)
243
+ }
244
+
245
+ /** What a finished recording or explore wrote, and what still has to be set before it replays. */
246
+ export function recordingNotes(cfg: AppConfig, result: DesktopResult): string[] {
247
+ const notes: string[] = []
248
+ const missing = (result.secrets || []).filter((n) => !cfg.desktop.secrets.includes(n))
249
+ if (missing.length) notes.push(`The workflow types ${missing.join(", ")} from password fields: set ${missing.length === 1 ? "it" : "them"} as environment variables and add ${missing.length === 1 ? "it" : "them"} to desktop.secrets (or the environment's secrets in env.yaml).`)
250
+ if (result.workflow && (result.checks ?? 0) === 0) notes.push(`No checks: replay proves only that the steps run. Record again with Ctrl+Shift+F11 over a control that shows the outcome, or add expect steps.`)
251
+ return notes
252
+ }
@@ -0,0 +1,134 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { engineEnv, ensureUv, run, uvEnv } from "../engine/setup.ts"
6
+ import { forgeqeHome, type Log } from "../tools.ts"
7
+
8
+ /**
9
+ * The desktop engine's own Python environment (~/.forgeqe/desktop/.venv), separate from the browser engine's: UFO pins
10
+ * its libraries (openai, pydantic) at versions the browser engine's library does not run with. Windows only.
11
+ */
12
+
13
+ /** The UFO commit the desktop engine is tested with; also in py/forgeqe_desktop/ufo_runner.py (a test checks they match). */
14
+ export const UFO_COMMIT = "eae3c0a8d168c47cb10df0652e293e092eda1078"
15
+ export const UFO_REPO = "https://github.com/microsoft/UFO.git"
16
+
17
+ /**
18
+ * The UFO commit to set up: UFO_COMMIT, or FORGEQE_UFO_COMMIT when it is a full 40-character commit id, which is how
19
+ * the UFO update check (.github/workflows/forgeqe-desktop-ufo-update.yml) tries a newer UFO before it is pinned.
20
+ * Anything shorter or other than hex is refused, so the version set up is always exact.
21
+ */
22
+ export function ufoCommit(env: NodeJS.ProcessEnv = process.env): string {
23
+ const raw = (env.FORGEQE_UFO_COMMIT || "").trim()
24
+ if (!raw) return UFO_COMMIT
25
+ if (!/^[0-9a-f]{40}$/.test(raw)) throw new Error("FORGEQE_UFO_COMMIT must be a full 40-character UFO commit id (lowercase hex).")
26
+ return raw
27
+ }
28
+
29
+ /** UFO's requirements pin pandas 1.4.3, which has Windows wheels up to Python 3.10 only. */
30
+ export const DESKTOP_PYTHON = "3.10"
31
+
32
+ /** Replay's libraries, at the versions UFO's requirements.txt pins, so one environment runs both. */
33
+ export const DESKTOP_PACKAGES = ["pywinauto==0.6.8", "PyYAML==6.0.1", "openai==1.66.2", "azure-identity==1.16.1", "Pillow==11.3.0", "comtypes==1.2.0"]
34
+
35
+ export const NOT_WINDOWS = "forgeqe desktop drives Windows apps, so explore, replay and setup run on Windows. validate and apps work anywhere."
36
+
37
+ export function desktopHome(): string {
38
+ return path.join(forgeqeHome(), "desktop")
39
+ }
40
+
41
+ function venvDir(): string {
42
+ return path.join(desktopHome(), ".venv")
43
+ }
44
+
45
+ export function desktopPython(platform: string = process.platform): string {
46
+ return platform === "win32" ? path.join(venvDir(), "Scripts", "python.exe") : path.join(venvDir(), "bin", "python")
47
+ }
48
+
49
+ export function ufoDir(): string {
50
+ return path.join(desktopHome(), `ufo-${ufoCommit().slice(0, 12)}`)
51
+ }
52
+
53
+ function markerFile(): string {
54
+ return path.join(desktopHome(), "installed.json")
55
+ }
56
+
57
+ type Marker = { packages?: string[]; ufo?: string; python?: string }
58
+
59
+ function marker(): Marker {
60
+ try {
61
+ return JSON.parse(readFileSync(markerFile(), "utf8")) as Marker
62
+ } catch {
63
+ return {}
64
+ }
65
+ }
66
+
67
+ export type DesktopDoctor = { python: string; ready: boolean; ufo: boolean; error?: string }
68
+
69
+ /** Whether the environment imports what replay needs, and whether the pinned UFO checkout is in place. */
70
+ export function desktopDoctor(python: string = desktopPython()): DesktopDoctor {
71
+ if (!existsSync(python)) return { python, ready: false, ufo: false, error: "the desktop engine is not set up" }
72
+ const r = spawnSync(python, ["-c", "import pywinauto, yaml, openai, forgeqe_desktop.service"], { env: engineEnv(), encoding: "utf8" })
73
+ const ready = !r.error && r.status === 0
74
+ const m = marker()
75
+ const ufo = m.ufo === ufoCommit() && existsSync(path.join(ufoDir(), "ufo", "__init__.py"))
76
+ return { python, ready, ufo, ...(ready ? {} : { error: (r.stderr || r.error?.message || "").trim().split(/\r?\n/).slice(-1)[0] || "imports failed" }) }
77
+ }
78
+
79
+ function gitHead(dir: string): string {
80
+ const r = spawnSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" })
81
+ return r.status === 0 ? r.stdout.trim() : ""
82
+ }
83
+
84
+ /** Clone UFO and check out exactly ufoCommit(); the commit id is the content's hash, so a matching HEAD is the pinned code. */
85
+ async function ensureUfo(log: Log): Promise<string> {
86
+ const commit = ufoCommit()
87
+ const dir = ufoDir()
88
+ if (existsSync(dir) && gitHead(dir) === commit) return dir
89
+ const git = spawnSync("git", ["--version"], { encoding: "utf8" })
90
+ if (git.error || git.status !== 0) throw new Error("Setting up UFO needs git on PATH (https://git-scm.com/download/win).")
91
+ rmSync(dir, { recursive: true, force: true })
92
+ log(`Fetching UFO ${commit.slice(0, 12)}${commit === UFO_COMMIT ? "" : " (FORGEQE_UFO_COMMIT, not the pinned one)"} into ${dir}.`)
93
+ await run("git", ["clone", "--filter=blob:none", "--no-checkout", UFO_REPO, dir], log)
94
+ await run("git", ["-C", dir, "checkout", "--detach", commit], log)
95
+ const head = gitHead(dir)
96
+ if (head !== commit) {
97
+ rmSync(dir, { recursive: true, force: true })
98
+ throw new Error(`UFO checkout is at ${head || "nothing"}, not ${commit}; removed it.`)
99
+ }
100
+ return dir
101
+ }
102
+
103
+ /**
104
+ * Set up the desktop engine: its environment with replay's libraries, and with `ufo` the pinned UFO checkout and its
105
+ * requirements (large: UFO's retrieval needs PyTorch). Returns the environment's Python.
106
+ */
107
+ export async function ensureDesktop(log: Log, opts: { ufo?: boolean; force?: boolean; platform?: string } = {}): Promise<string> {
108
+ if ((opts.platform || process.platform) !== "win32") throw new Error(NOT_WINDOWS)
109
+ const python = desktopPython()
110
+ const m: Marker = opts.force ? {} : marker()
111
+ const packagesCurrent = JSON.stringify(m.packages || []) === JSON.stringify(DESKTOP_PACKAGES) && m.python === DESKTOP_PYTHON
112
+ if (!opts.force && packagesCurrent && existsSync(python) && (!opts.ufo || m.ufo === ufoCommit()) && desktopDoctor(python).ready) return python
113
+
114
+ // An environment on another Python (an earlier ForgeQE used 3.11) is rebuilt rather than mixed.
115
+ if (opts.force || (existsSync(python) && m.python !== DESKTOP_PYTHON)) rmSync(venvDir(), { recursive: true, force: true })
116
+ mkdirSync(desktopHome(), { recursive: true })
117
+ const uv = await ensureUv(log)
118
+ if (!uv) throw new Error("The desktop engine needs uv to set up its Python; uv could not be downloaded. Set HTTPS_PROXY if you are behind a proxy.")
119
+ log(`Setting up the desktop engine in ${venvDir()}.`)
120
+ if (!existsSync(python)) await run(uv, ["venv", "--python", DESKTOP_PYTHON, venvDir()], log, uvEnv(uv))
121
+ const extra: string[] = []
122
+ let ufo: string | undefined
123
+ if (opts.ufo) {
124
+ const dir = await ensureUfo(log)
125
+ extra.push("-r", path.join(dir, "requirements.txt"))
126
+ ufo = ufoCommit()
127
+ }
128
+ await run(uv, ["pip", "install", "--python", python, ...DESKTOP_PACKAGES, ...extra], log, uvEnv(uv), "uv pip install <desktop packages>")
129
+ const kept = m.python === DESKTOP_PYTHON ? m.ufo : undefined
130
+ writeFileSync(markerFile(), JSON.stringify({ packages: DESKTOP_PACKAGES, python: DESKTOP_PYTHON, ...(ufo || kept ? { ufo: ufo || kept } : {}) }, null, 2))
131
+ const doc = desktopDoctor(python)
132
+ if (!doc.ready) throw new Error(`The desktop engine was installed but does not start: ${doc.error}`)
133
+ return python
134
+ }
@@ -11,6 +11,8 @@ import type { BrowserProvider, BrowserRemoteConfig, LoginConfig } from "../confi
11
11
  import { debugLogging, logEvent } from "../sessionLog.ts"
12
12
  import { engineEnv } from "./setup.ts"
13
13
  import { AZURE_IDENTITY_ENV, entraPayload } from "../entra.ts"
14
+ import { ghAuthToken } from "../cases/github.ts"
15
+ import { engineRoute } from "../copilot.ts"
14
16
 
15
17
  export type EngineRequest = {
16
18
  goal: string
@@ -152,7 +154,7 @@ function toMemoryInfo(m: EngineMemory | undefined): MemoryInfo | undefined {
152
154
  }
153
155
  }
154
156
 
155
- function llmPayload(p: LlmProfile): Record<string, unknown> {
157
+ export function llmPayload(p: LlmProfile): Record<string, unknown> {
156
158
  const out: Record<string, unknown> = { provider: p.provider, model: p.model }
157
159
  if (p.endpoint) out.endpoint = p.endpoint
158
160
  if (p.deployment) out.deployment = p.deployment
@@ -161,6 +163,8 @@ function llmPayload(p: LlmProfile): Record<string, unknown> {
161
163
  if (p.auth === "entra") Object.assign(out, entraPayload(p))
162
164
  else if (p.apiKeyEnv) out.api_key_env = p.apiKeyEnv
163
165
  if (typeof p.temperature === "number") out.temperature = p.temperature
166
+ if (typeof p.vision === "boolean") out.vision = p.vision
167
+ if (typeof p.reasoning === "boolean") out.reasoning = p.reasoning
164
168
  return out
165
169
  }
166
170
 
@@ -240,12 +244,15 @@ export async function runEngine(opts: {
240
244
  // Each model call goes to the usage ledger under this run (its folder's name) and case; what the per-call events
241
245
  // missed (an engine that sends only a total) is recorded as one unattributed call when the run ends.
242
246
  const scope = { runId: path.basename(req.outDir), ...(req.caseId ? { caseId: req.caseId } : {}) }
243
- const model = req.llm.deployment || req.llm.model
247
+ // A github_copilot profile's engine runs on GitHub Models; its token goes only into the engine's environment.
248
+ const route = engineRoute(req.llm, ghAuthToken)
249
+ const llm = route.profile
250
+ const model = llm.deployment || llm.model
244
251
  const callTotal: Usage = { input: 0, output: 0 }
245
252
  const [bin, ...args] = opts.command || [opts.python, "-m", "forgeqe_engine", "run"]
246
253
  const child = spawn(bin!, args, {
247
- env: engineEnv(debugLogging() ? { ...req.env, BROWSER_USE_LOGGING_LEVEL: "debug" } : req.env, [
248
- ...(req.llm.auth === "entra" ? AZURE_IDENTITY_ENV : req.llm.apiKeyEnv ? [req.llm.apiKeyEnv] : []),
254
+ env: engineEnv({ ...req.env, ...(debugLogging() ? { BROWSER_USE_LOGGING_LEVEL: "debug" } : {}), ...route.env }, [
255
+ ...(llm.auth === "entra" ? AZURE_IDENTITY_ENV : llm.apiKeyEnv ? [llm.apiKeyEnv] : []),
249
256
  ...browserProviderEnvNames(req.browser || { provider: "local" }),
250
257
  ...loginEnvNames(req.logins || []),
251
258
  ]),
@@ -266,7 +273,7 @@ export async function runEngine(opts: {
266
273
  const payload = {
267
274
  goal: req.goal,
268
275
  out_dir: req.outDir,
269
- llm: llmPayload(req.llm),
276
+ llm: llmPayload(llm),
270
277
  start_url: req.startUrl || null,
271
278
  headed: req.headed,
272
279
  max_steps: req.maxSteps,
@@ -68,6 +68,9 @@ export function engineEnv(extra?: Record<string, string>, allow: string[] = []):
68
68
  const env = allowListEnv(process.env, allow)
69
69
  env.PYTHONPATH = [ENGINE_SOURCE, env.PYTHONPATH].filter(Boolean).join(path.delimiter)
70
70
  env.PYTHONUNBUFFERED = "1"
71
+ // Requests, prompts and logs are UTF-8; Windows would otherwise read pipes and files in its ANSI code page.
72
+ env.PYTHONUTF8 = "1"
73
+ env.PYTHONIOENCODING = "utf-8"
71
74
  return { ...env, ...extra }
72
75
  }
73
76
 
@@ -78,7 +81,7 @@ function managedUv(): string {
78
81
  }
79
82
 
80
83
  /** A uv ForgeQE downloaded keeps its Pythons under ~/.forgeqe too, so deleting that folder removes everything. */
81
- function uvEnv(uv: string): Record<string, string> {
84
+ export function uvEnv(uv: string): Record<string, string> {
82
85
  return uv === managedUv() ? { UV_PYTHON_INSTALL_DIR: path.join(toolsDir(), "python") } : {}
83
86
  }
84
87
 
@@ -103,7 +106,7 @@ export function uvAsset(platform: string = process.platform, arch: string = proc
103
106
  }
104
107
 
105
108
  /** System uv, else a uv ForgeQE downloaded earlier, else download it now. Null only when the download fails. */
106
- async function ensureUv(log: Log): Promise<string | null> {
109
+ export async function ensureUv(log: Log): Promise<string | null> {
107
110
  const found = findUv()
108
111
  if (found) return found
109
112
  try {
@@ -142,7 +145,7 @@ function findSystemPython(): string | null {
142
145
  return null
143
146
  }
144
147
 
145
- function run(cmd: string, args: string[], log: Log, env: Record<string, string> = {}, shown?: string): Promise<void> {
148
+ export function run(cmd: string, args: string[], log: Log, env: Record<string, string> = {}, shown?: string): Promise<void> {
146
149
  log(`$ ${shown || [path.basename(cmd), ...args].join(" ")}`)
147
150
  return new Promise((resolve, reject) => {
148
151
  const child = spawn(cmd, args, {