@capagents/forgeqe 0.0.0-stage → 1.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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,177 @@
1
+ import { existsSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import nodemailer from "nodemailer"
6
+
7
+ import type { EmailConfig, MonitorAlerts } from "../config.ts"
8
+
9
+ /**
10
+ * One notification, whatever it is about (a monitor round, a suite, a release verdict, an evaluation), and how it
11
+ * looks in Teams (an Adaptive Card, which incoming webhooks and Workflows webhooks both accept) and in email.
12
+ */
13
+
14
+ export type Status = "good" | "bad" | "warn"
15
+ export type Link = { title: string; url: string }
16
+
17
+ export type NoticeItem = { line: string; status?: Status; details?: string[]; links?: Link[] }
18
+
19
+ export type Notice = {
20
+ /** What sent it, before the title: "ForgeQE monitor", "ForgeQE suite". */
21
+ source: string
22
+ title: string
23
+ status: Status
24
+ /** One line under the title: when, how many, where. */
25
+ subtitle: string
26
+ items: NoticeItem[]
27
+ /** Buttons under the card (Open report, Open build) and links at the end of the email. */
28
+ actions?: Link[]
29
+ /** A last line for what has no link, e.g. the report's path on the machine that ran it. */
30
+ note?: string
31
+ /** Email only: files to attach, such as failure screenshots. */
32
+ attachments?: { filename: string; path: string }[]
33
+ }
34
+
35
+ export type Sent = { channel: "teams" | "email"; ok: boolean; error?: string }
36
+
37
+ const CARD_COLOR: Record<Status, string> = { good: "Good", bad: "Attention", warn: "Warning" }
38
+ const MAIL_COLOR: Record<Status, string> = { good: "#067647", bad: "#b42318", warn: "#b54708" }
39
+
40
+ export function teamsCard(n: Notice): Record<string, unknown> {
41
+ const body: Record<string, unknown>[] = [
42
+ { type: "TextBlock", text: `${n.source}: ${n.title}`, weight: "Bolder", size: "Medium", wrap: true, color: CARD_COLOR[n.status] },
43
+ { type: "TextBlock", text: n.subtitle, isSubtle: true, spacing: "None", wrap: true },
44
+ ]
45
+ for (const i of n.items) {
46
+ body.push({
47
+ type: "Container",
48
+ separator: true,
49
+ items: [
50
+ { type: "TextBlock", text: i.line, weight: "Bolder", wrap: true, ...(i.status ? { color: CARD_COLOR[i.status] } : {}) },
51
+ ...(i.details || []).map((text) => ({ type: "TextBlock", text, wrap: true, isSubtle: true, spacing: "Small" })),
52
+ ...(i.links?.length ? [{ type: "TextBlock", text: i.links.map((l) => `[${l.title}](${l.url})`).join(" · "), spacing: "Small", wrap: true }] : []),
53
+ ],
54
+ })
55
+ }
56
+ if (n.note) body.push({ type: "TextBlock", text: n.note, isSubtle: true, wrap: true, size: "Small" })
57
+ return {
58
+ type: "message",
59
+ attachments: [
60
+ {
61
+ contentType: "application/vnd.microsoft.card.adaptive",
62
+ contentUrl: null,
63
+ content: {
64
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
65
+ type: "AdaptiveCard",
66
+ version: "1.4",
67
+ msteams: { width: "Full" },
68
+ body,
69
+ ...(n.actions?.length ? { actions: n.actions.map((a) => ({ type: "Action.OpenUrl", title: a.title, url: a.url })) } : {}),
70
+ },
71
+ },
72
+ ],
73
+ }
74
+ }
75
+
76
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
77
+
78
+ export function noticeEmail(n: Notice): { subject: string; text: string; html: string; attachments: { filename: string; path: string }[] } {
79
+ const text = [
80
+ n.subtitle,
81
+ "",
82
+ ...n.items.flatMap((i) => [i.line, ...(i.details || []).map((d) => ` ${d}`), ...(i.links || []).map((l) => ` ${l.title}: ${l.url}`)]),
83
+ "",
84
+ ...(n.actions || []).map((a) => `${a.title}: ${a.url}`),
85
+ n.note || "",
86
+ ]
87
+ .filter((l, idx, all) => l !== "" || (idx > 0 && all[idx - 1] !== ""))
88
+ .join("\n")
89
+ .trim()
90
+ const rows = n.items
91
+ .map((i) => {
92
+ const color = i.status ? MAIL_COLOR[i.status] : "#111"
93
+ const details = (i.details || []).map((d) => `<div style="color:#555">${esc(d)}</div>`).join("")
94
+ const links = i.links?.length ? `<div>${i.links.map((l) => `<a href="${esc(l.url)}">${esc(l.title)}</a>`).join(" · ")}</div>` : ""
95
+ return `<tr><td style="padding:8px 0;border-top:1px solid #eee"><div style="color:${color};font-weight:600">${esc(i.line)}</div>${details}${links}</td></tr>`
96
+ })
97
+ .join("")
98
+ const actions = n.actions?.length ? `<p>${n.actions.map((a) => `<a href="${esc(a.url)}">${esc(a.title)}</a>`).join(" · ")}</p>` : ""
99
+ const note = n.note ? `<p style="color:#555">${esc(n.note)}</p>` : ""
100
+ const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px"><h2 style="margin:0 0 4px;color:${MAIL_COLOR[n.status]}">${esc(`${n.source}: ${n.title}`)}</h2><p style="color:#555;margin:0 0 12px">${esc(n.subtitle)}</p><table style="border-collapse:collapse;width:100%">${rows}</table>${actions}${note}</div>`
101
+ return { subject: `[ForgeQE] ${n.title}`, text, html, attachments: (n.attachments || []).filter((a) => existsSync(a.path)) }
102
+ }
103
+
104
+ export type Mailer = { sendMail(mail: Record<string, unknown>): Promise<unknown> }
105
+
106
+ export function smtpTransport(cfg: EmailConfig): Mailer {
107
+ const user = process.env[cfg.usernameEnv]
108
+ const pass = process.env[cfg.passwordEnv]
109
+ return nodemailer.createTransport({
110
+ host: cfg.host,
111
+ port: cfg.port,
112
+ secure: cfg.secure,
113
+ ...(user ? { auth: { user, pass: pass || "" } } : {}),
114
+ connectionTimeout: 20_000,
115
+ greetingTimeout: 20_000,
116
+ socketTimeout: 30_000,
117
+ })
118
+ }
119
+
120
+ export async function sendTeams(webhook: string, n: Notice, fetcher: typeof fetch = fetch): Promise<void> {
121
+ const res = await fetcher(webhook, {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify(teamsCard(n)),
125
+ signal: AbortSignal.timeout(20_000),
126
+ })
127
+ if (!res.ok) {
128
+ const text = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 200)
129
+ throw new Error(`the Teams webhook answered ${res.status}${text ? `: ${text}` : ""}`)
130
+ }
131
+ }
132
+
133
+ export async function sendEmail(cfg: EmailConfig, n: Notice, transport: Mailer = smtpTransport(cfg)): Promise<void> {
134
+ const c = noticeEmail(n)
135
+ const from = cfg.from || process.env[cfg.usernameEnv] || `forgeqe@${os.hostname()}`
136
+ await transport.sendMail({ from, to: cfg.to.join(", "), subject: c.subject, text: c.text, html: c.html, attachments: c.attachments })
137
+ }
138
+
139
+ export type SendDeps = { fetch?: typeof fetch; mailer?: (cfg: EmailConfig) => Mailer }
140
+
141
+ /** Send to every channel of `to`; one that fails does not stop the others, and no secret reaches an error. */
142
+ export async function sendNotice(to: MonitorAlerts, n: Notice, deps: SendDeps = {}): Promise<Sent[]> {
143
+ const sent: Sent[] = []
144
+ if (to.teams) {
145
+ const url = process.env[to.teams.webhookEnv]
146
+ if (!url) sent.push({ channel: "teams", ok: false, error: `${to.teams.webhookEnv} is not set` })
147
+ else {
148
+ try {
149
+ await sendTeams(url, n, deps.fetch)
150
+ sent.push({ channel: "teams", ok: true })
151
+ } catch (err) {
152
+ // The webhook URL is a secret; errors from fetch can contain it.
153
+ sent.push({ channel: "teams", ok: false, error: ((err as Error).message || String(err)).split(url).join("<webhook>") })
154
+ }
155
+ }
156
+ }
157
+ if (to.email) {
158
+ try {
159
+ await sendEmail(to.email, n, deps.mailer ? deps.mailer(to.email) : undefined)
160
+ sent.push({ channel: "email", ok: true })
161
+ } catch (err) {
162
+ const pass = process.env[to.email.passwordEnv]
163
+ const msg = (err as Error).message || String(err)
164
+ sent.push({ channel: "email", ok: false, error: pass ? msg.split(pass).join("<password>") : msg })
165
+ }
166
+ }
167
+ return sent
168
+ }
169
+
170
+ /** "screenshot of SHOP-12": a file name safe for an email attachment. */
171
+ export function attachmentName(id: string, suffix: string): string {
172
+ return `${id.replace(/[^A-Za-z0-9._-]+/g, "-")}-${suffix}`
173
+ }
174
+
175
+ export function failureShot(runDir: string | undefined, id: string): { filename: string; path: string } | null {
176
+ return runDir ? { filename: attachmentName(id, "failure.png"), path: path.join(runDir, "failure.png") } : null
177
+ }
package/src/prices.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { forgeqeHome } from "./tools.ts"
5
+
6
+ /**
7
+ * Models' list prices, from two public price lists: models.dev (the list the agent runtime itself uses) and LiteLLM's
8
+ * model_prices_and_context_window.json, which also has Azure's Data Zone prices. ForgeQE keeps the few fields it needs
9
+ * for the providers it supports in ~/.forgeqe/prices.json and refreshes it once a day; nothing about a run is sent,
10
+ * the lists are plain downloads. A price in llms.json always wins (a negotiated or enterprise rate).
11
+ */
12
+
13
+ export const MODELS_DEV_URL = "https://models.dev/api.json"
14
+ export const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
15
+ const MAX_AGE_MS = 24 * 3600_000
16
+ const FETCH_TIMEOUT_MS = 15_000
17
+
18
+ /** Per million tokens, in US dollars. `above` is the price once one request's input passes `tokens`. */
19
+ export type ListPrice = {
20
+ input: number
21
+ output: number
22
+ cacheRead?: number
23
+ cacheWrite?: number
24
+ above?: { tokens: number; input: number; output: number; cacheRead?: number }
25
+ source: "models.dev" | "LiteLLM"
26
+ }
27
+
28
+ type PriceFile = { fetchedAt: string; sources: string[]; models: Record<string, ListPrice> }
29
+
30
+ /** Providers ForgeQE's profiles use, as the lists name them. */
31
+ const PROVIDERS: Record<string, string[]> = {
32
+ openai: ["openai"],
33
+ azure: ["azure", "openai"],
34
+ anthropic: ["anthropic"],
35
+ gemini: ["google"],
36
+ google: ["google"],
37
+ mistral: ["mistral"],
38
+ deepseek: ["deepseek"],
39
+ openrouter: ["openrouter"],
40
+ }
41
+
42
+ const file = () => process.env.FORGEQE_PRICES_FILE || path.join(forgeqeHome(), "prices.json")
43
+ const offline = () => /^(0|off|false|no)$/i.test(process.env.FORGEQE_PRICES || "")
44
+
45
+ async function getJson(url: string): Promise<unknown> {
46
+ const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
47
+ if (!res.ok) throw new Error(`${url}: ${res.status}`)
48
+ return res.json()
49
+ }
50
+
51
+ const perM = (perToken: unknown) => (typeof perToken === "number" && Number.isFinite(perToken) ? Math.round(perToken * 1e6 * 1e6) / 1e6 : undefined)
52
+
53
+ /** models.dev: { provider: { models: { id: { cost: { input, output, cache_read, cache_write, tiers } } } } }, already per million. */
54
+ export function fromModelsDev(data: unknown): Record<string, ListPrice> {
55
+ const out: Record<string, ListPrice> = {}
56
+ const wanted = new Set(Object.values(PROVIDERS).flat())
57
+ for (const [provider, entry] of Object.entries((data || {}) as Record<string, { models?: Record<string, { cost?: Record<string, any> }> }>)) {
58
+ if (!wanted.has(provider)) continue
59
+ for (const [id, model] of Object.entries(entry?.models || {})) {
60
+ const c = model?.cost
61
+ if (!c || typeof c.input !== "number" || typeof c.output !== "number") continue
62
+ const tier = Array.isArray(c.tiers) ? c.tiers.find((t: any) => t?.tier?.type === "context" && Number(t.tier.size) > 0) : undefined
63
+ out[`${provider}/${id.toLowerCase()}`] = {
64
+ input: c.input,
65
+ output: c.output,
66
+ ...(typeof c.cache_read === "number" ? { cacheRead: c.cache_read } : {}),
67
+ ...(typeof c.cache_write === "number" ? { cacheWrite: c.cache_write } : {}),
68
+ ...(tier ? { above: { tokens: Number(tier.tier.size), input: tier.input, output: tier.output, ...(typeof tier.cache_read === "number" ? { cacheRead: tier.cache_read } : {}) } } : {}),
69
+ source: "models.dev",
70
+ }
71
+ }
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** LiteLLM: { "azure/eu/gpt-5.4": { input_cost_per_token, output_cost_per_token, cache_read_input_token_cost, … } }, per token. */
77
+ export function fromLiteLLM(data: unknown): Record<string, ListPrice> {
78
+ const out: Record<string, ListPrice> = {}
79
+ const wanted = new Set(Object.values(PROVIDERS).flat())
80
+ for (const [key, e] of Object.entries((data || {}) as Record<string, Record<string, unknown>>)) {
81
+ const input = perM(e?.input_cost_per_token)
82
+ const output = perM(e?.output_cost_per_token)
83
+ if (input === undefined || output === undefined) continue
84
+ const provider = String(e.litellm_provider || key.split("/")[0])
85
+ const name = key.includes("/") ? key : `${provider}/${key}`
86
+ if (!wanted.has(name.split("/")[0])) continue
87
+ const cacheRead = perM(e.cache_read_input_token_cost)
88
+ const cacheWrite = perM(e.cache_creation_input_token_cost)
89
+ const aboveKey = Object.keys(e).find((k) => /^input_cost_per_token_above_(\d+)k_tokens$/.test(k))
90
+ const aboveTokens = aboveKey ? Number(/_(\d+)k_/.exec(aboveKey)![1]) * 1000 : 0
91
+ const sfx = aboveTokens ? `_above_${aboveTokens / 1000}k_tokens` : ""
92
+ out[name.toLowerCase()] = {
93
+ input,
94
+ output,
95
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
96
+ ...(cacheWrite !== undefined ? { cacheWrite } : {}),
97
+ ...(aboveTokens && perM(e[`input_cost_per_token${sfx}`]) !== undefined && perM(e[`output_cost_per_token${sfx}`]) !== undefined
98
+ ? {
99
+ above: {
100
+ tokens: aboveTokens,
101
+ input: perM(e[`input_cost_per_token${sfx}`])!,
102
+ output: perM(e[`output_cost_per_token${sfx}`])!,
103
+ ...(perM(e[`cache_read_input_token_cost${sfx}`]) !== undefined ? { cacheRead: perM(e[`cache_read_input_token_cost${sfx}`]) } : {}),
104
+ },
105
+ }
106
+ : {}),
107
+ source: "LiteLLM",
108
+ }
109
+ }
110
+ return out
111
+ }
112
+
113
+ let loaded: PriceFile | null | undefined
114
+
115
+ function readFile(): PriceFile | null {
116
+ if (loaded !== undefined) return loaded
117
+ try {
118
+ loaded = JSON.parse(readFileSync(file(), "utf8")) as PriceFile
119
+ } catch {
120
+ loaded = null
121
+ }
122
+ return loaded
123
+ }
124
+
125
+ /** Download both lists and keep what ForgeQE needs; models.dev wins where both have a model. Returns what was saved. */
126
+ export async function refreshPrices(get: (url: string) => Promise<unknown> = getJson): Promise<PriceFile | null> {
127
+ const [dev, lite] = await Promise.allSettled([get(MODELS_DEV_URL).then(fromModelsDev), get(LITELLM_URL).then(fromLiteLLM)])
128
+ const sources = [dev.status === "fulfilled" ? "models.dev" : "", lite.status === "fulfilled" ? "LiteLLM" : ""].filter(Boolean)
129
+ if (!sources.length) return null
130
+ const models = { ...(lite.status === "fulfilled" ? lite.value : {}), ...(dev.status === "fulfilled" ? dev.value : {}) }
131
+ const data: PriceFile = { fetchedAt: new Date().toISOString(), sources, models }
132
+ mkdirSync(path.dirname(file()), { recursive: true })
133
+ const tmp = `${file()}.${process.pid}`
134
+ writeFileSync(tmp, JSON.stringify(data))
135
+ renameSync(tmp, file())
136
+ loaded = data
137
+ return data
138
+ }
139
+
140
+ /**
141
+ * Make sure there is a price list: the first time, wait (a few seconds at most) for one; after that, refresh a list
142
+ * older than a day in the background. Never throws; offline or with FORGEQE_PRICES=off it uses what it has.
143
+ */
144
+ export async function ensurePrices(): Promise<void> {
145
+ if (offline()) return
146
+ const have = existsSync(file()) ? readFile() : null
147
+ const stale = !have || Date.now() - Date.parse(have.fetchedAt) > MAX_AGE_MS
148
+ if (!stale) return
149
+ const job = refreshPrices().catch(() => null)
150
+ if (!have) await Promise.race([job, new Promise((r) => setTimeout(r, 8_000))])
151
+ }
152
+
153
+ /** "Global" (the default), or "data_zone" for an Azure Data Zone deployment, which costs about 10% more. */
154
+ export type DeploymentType = "global" | "data_zone"
155
+
156
+ /** The list price of a profile's model, or null when neither list has it. */
157
+ export function listPrice(provider: string, model: string, deployment: DeploymentType = "global", prices: Record<string, ListPrice> | undefined = readFile()?.models): ListPrice | null {
158
+ if (!prices) return null
159
+ const ids = [model.toLowerCase(), model.toLowerCase().replace(/-\d{4}-\d{2}-\d{2}$/, "")]
160
+ const keys: string[] = []
161
+ if (provider === "azure" && deployment === "data_zone") for (const id of ids) keys.push(`azure/eu/${id}`, `azure/us/${id}`)
162
+ for (const p of PROVIDERS[provider] || [provider]) for (const id of ids) keys.push(`${p}/${id}`)
163
+ for (const k of keys) if (prices[k]) return prices[k]
164
+ return null
165
+ }
166
+
167
+ /** When the list was fetched, for cost notes ("list price, models.dev, 2026-10-06"). */
168
+ export function pricesDate(): string | undefined {
169
+ return readFile()?.fetchedAt?.slice(0, 10)
170
+ }
171
+
172
+ /** Forget the loaded list (tests, or after the home folder changed). */
173
+ export function resetPrices() {
174
+ loaded = undefined
175
+ }
package/src/print.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { briefText, shows, stepIntent, type Detail } from "./detail.ts"
2
+ import type { AgentEvent } from "./types.ts"
3
+
4
+ const c = {
5
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
6
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
7
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
8
+ red: (s: string) => `\x1b[31m${s}\x1b[0m`,
9
+ yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
10
+ magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
11
+ }
12
+
13
+ export function formatEvent(event: AgentEvent, color = true, detail: Detail = "brief"): string | null {
14
+ const paint = (fn: (s: string) => string, text: string) => (color ? fn(text) : text)
15
+ if (event.type !== "done" && !shows(event, detail)) return null
16
+ switch (event.type) {
17
+ case "log":
18
+ return event.level === "error"
19
+ ? paint(c.red, event.text)
20
+ : event.level === "warn"
21
+ ? paint(c.yellow, event.text)
22
+ : paint(c.dim, event.text)
23
+ case "step":
24
+ return paint(
25
+ c.dim,
26
+ `step ${event.step} ${[event.source === "memory" ? "(memory)" : event.category, event.title || "untitled", event.url].filter(Boolean).join(" ")}`,
27
+ )
28
+ case "memory":
29
+ return paint(c.magenta, `↻ ${event.text}`)
30
+ case "thought":
31
+ return paint(c.cyan, detail === "full" ? ` ${event.text}` : stepIntent(event.step, event.text, detail))
32
+ case "outcome":
33
+ return ` ${event.ok ? paint(c.green, "ok") : paint(c.red, "fail")} ${briefText(event.text, detail)}`
34
+ case "usage":
35
+ return null
36
+ case "done":
37
+ return `${event.result.status}: ${event.result.summary}`
38
+ case "code":
39
+ switch (event.kind) {
40
+ case "info":
41
+ return paint(c.magenta, `▸ ${event.text}`)
42
+ case "tool":
43
+ return event.ok ? paint(c.dim, ` ⚙ ${event.text}`) : paint(c.yellow, ` ⚙ ${event.text}`)
44
+ case "say":
45
+ return paint(c.cyan, ` ${event.text}`)
46
+ case "test":
47
+ return event.ok ? paint(c.green, `✓ ${event.text}`) : paint(c.red, `✗ ${event.text}`)
48
+ default:
49
+ return null
50
+ }
51
+ default:
52
+ return null
53
+ }
54
+ }
package/src/privacy.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Environment for every process ForgeQE starts (the ForgeQE engine, the agent runtime, the repo's test command) that
3
+ * turns off usage telemetry, cloud sync, update checks, and trace export.
4
+ */
5
+ export const NO_TELEMETRY_ENV: Record<string, string> = {
6
+ // engine library: PostHog product telemetry, cloud sync, and the LiteLLM price-list download
7
+ ANONYMIZED_TELEMETRY: "false",
8
+ BROWSER_USE_CLOUD_SYNC: "false",
9
+ BROWSER_USE_CALCULATE_COST: "false",
10
+ // agent runtime: auto-update, session sharing, and the models.dev catalog download (the bundled catalog is used)
11
+ OPENCODE_DISABLE_AUTOUPDATE: "1",
12
+ OPENCODE_DISABLE_SHARE: "1",
13
+ OPENCODE_DISABLE_MODELS_FETCH: "1",
14
+ // OpenTelemetry SDKs, and tools a repo's test command may start
15
+ OTEL_SDK_DISABLED: "true",
16
+ DO_NOT_TRACK: "1",
17
+ NEXT_TELEMETRY_DISABLED: "1",
18
+ NUXT_TELEMETRY_DISABLED: "1",
19
+ ASTRO_TELEMETRY_DISABLED: "1",
20
+ GATSBY_TELEMETRY_DISABLED: "1",
21
+ STORYBOOK_DISABLE_TELEMETRY: "1",
22
+ TURBO_TELEMETRY_DISABLED: "1",
23
+ NG_CLI_ANALYTICS: "false",
24
+ DOTNET_CLI_TELEMETRY_OPTOUT: "1",
25
+ CYPRESS_CRASH_REPORTS: "0",
26
+ }
27
+
28
+ /** `env` with telemetry off and OTLP exporters removed, so no process sends traces anywhere. */
29
+ export function withoutTelemetry(env: Record<string, string | undefined>): Record<string, string> {
30
+ const out: Record<string, string> = {}
31
+ for (const [k, v] of Object.entries(env)) {
32
+ if (typeof v === "string" && !k.startsWith("OTEL_EXPORTER_OTLP_")) out[k] = v
33
+ }
34
+ return { ...out, ...NO_TELEMETRY_ENV }
35
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { basedOnOlder, copyState } from "./managed.ts"
5
+
6
+ /**
7
+ * Every prompt ForgeQE's agents use lives in a markdown file: prompts/<name>.md in ForgeQE, with YAML frontmatter
8
+ * (name, description, used_by) and the prompt as the body. A project changes one by editing its copy in
9
+ * .forgeqe/prompts/ (forgeqe init puts them there), or ~/.forgeqe/prompts/ for every project. The first of those
10
+ * that has an edited copy wins; an unedited copy keeps following ForgeQE's built-in prompt. {{> name}} includes
11
+ * another prompt (resolved the same way).
12
+ */
13
+
14
+ export const PROMPTS_DIR = path.resolve(import.meta.dir, "..", "prompts")
15
+ const INCLUDE = /\{\{>\s*([a-z0-9-]+)\s*\}\}/g
16
+
17
+ /** Folders with the project's and the user's prompts, highest precedence first (set when forgeqe.yaml is loaded). */
18
+ let overrideDirs: { dir: string; source: string }[] = []
19
+
20
+ export function setPromptDirs(dirs: { dir?: string; source: string }[]): void {
21
+ const seen = new Set<string>()
22
+ overrideDirs = dirs
23
+ .filter((d): d is { dir: string; source: string } => Boolean(d.dir))
24
+ .map((d) => ({ dir: path.resolve(d.dir), source: d.source }))
25
+ .filter((d) => !seen.has(d.dir) && Boolean(seen.add(d.dir)))
26
+ }
27
+
28
+ export function promptDirs(): { dir: string; source: string }[] {
29
+ return [...overrideDirs]
30
+ }
31
+
32
+ export type PromptSource = "built-in" | "project" | "user"
33
+
34
+ export type PromptInfo = {
35
+ name: string
36
+ description: string
37
+ usedBy: string
38
+ source: PromptSource | string
39
+ file: string
40
+ /** An edited copy whose built-in prompt changed since the copy was made. */
41
+ outdated: boolean
42
+ }
43
+
44
+ function parse(text: string): { meta: Record<string, string>; body: string } {
45
+ const src = text.replace(/^/, "").replace(/\r\n/g, "\n")
46
+ if (!src.startsWith("---\n")) return { meta: {}, body: src.trim() }
47
+ const end = src.indexOf("\n---", 4)
48
+ if (end < 0) return { meta: {}, body: src.trim() }
49
+ const meta: Record<string, string> = {}
50
+ for (const line of src.slice(4, end).split("\n")) {
51
+ const m = line.match(/^([a-z_]+):\s*(.*)$/)
52
+ if (!m) continue
53
+ let v = m[2].trim()
54
+ if (v.startsWith('"')) {
55
+ try {
56
+ v = JSON.parse(v)
57
+ } catch {}
58
+ }
59
+ meta[m[1]] = v
60
+ }
61
+ return { meta, body: src.slice(end + 4).replace(/^[^\n]*\n/, "").trim() }
62
+ }
63
+
64
+ /** Where a prompt comes from: the first edited (or hand-made) copy in an override folder, else the built-in file. */
65
+ export function promptFile(name: string): { file: string; source: string; outdated: boolean } {
66
+ const key = `${name}.md`
67
+ const builtin = path.join(PROMPTS_DIR, key)
68
+ for (const { dir, source } of overrideDirs) {
69
+ const state = copyState(dir, key)
70
+ if (state === "edited" || state === "own") return { file: path.join(dir, key), source, outdated: basedOnOlder(dir, key, builtin) }
71
+ }
72
+ return { file: builtin, source: "built-in", outdated: false }
73
+ }
74
+
75
+ const cache = new Map<string, { at: number; body: string }>()
76
+
77
+ function body(file: string): string {
78
+ let at = 0
79
+ try {
80
+ at = Bun.file(file).lastModified
81
+ } catch {}
82
+ const hit = cache.get(file)
83
+ if (hit && hit.at === at) return hit.body
84
+ const b = parse(readFileSync(file, "utf8")).body
85
+ cache.set(file, { at, body: b })
86
+ return b
87
+ }
88
+
89
+ /** A prompt's text, with its includes expanded. */
90
+ export function prompt(name: string, seen: string[] = []): string {
91
+ if (seen.includes(name)) throw new Error(`Prompt ${name} includes itself (${[...seen, name].join(" → ")}).`)
92
+ const { file } = promptFile(name)
93
+ if (!existsSync(file)) throw new Error(`No prompt named "${name}" (${file}).`)
94
+ return body(file).replace(INCLUDE, (_, inner: string) => prompt(inner, [...seen, name]))
95
+ }
96
+
97
+ /** Every prompt ForgeQE has, with where each comes from now. */
98
+ export function listPrompts(): PromptInfo[] {
99
+ return readdirSync(PROMPTS_DIR)
100
+ .filter((f) => f.endsWith(".md") && f !== "README.md")
101
+ .sort()
102
+ .map((f) => {
103
+ const name = f.replace(/\.md$/, "")
104
+ const { meta } = parse(readFileSync(path.join(PROMPTS_DIR, f), "utf8"))
105
+ const where = promptFile(name)
106
+ return { name, description: meta.description || "", usedBy: meta.used_by || "", source: where.source, file: where.file, outdated: where.outdated }
107
+ })
108
+ }
109
+
110
+ /** Edited prompts based on an older built-in: worth a look after updating ForgeQE. */
111
+ export function promptWarnings(): string[] {
112
+ return listPrompts()
113
+ .filter((p) => p.outdated)
114
+ .map((p) => `Your prompt ${p.name} (${p.file}) is based on an older version of ForgeQE's; forgeqe prompts diff ${p.name} shows what changed.`)
115
+ }
116
+
117
+ /** The browser engine's prompts (browser-*.md), resolved, for the engine to use instead of its own copies. */
118
+ export function enginePrompts(): Record<string, string> {
119
+ const out: Record<string, string> = {}
120
+ for (const f of readdirSync(PROMPTS_DIR)) {
121
+ const name = f.replace(/\.md$/, "")
122
+ if (!f.endsWith(".md") || !name.startsWith("browser-") || name === "browser-extra") continue
123
+ out[name] = prompt(name)
124
+ }
125
+ return out
126
+ }
127
+
128
+ /** The built-in text of a prompt (without includes expanded), for comparing with an edited copy. */
129
+ export function builtinPrompt(name: string): string {
130
+ return parse(readFileSync(path.join(PROMPTS_DIR, `${name}.md`), "utf8")).body
131
+ }
132
+
133
+ /** Lines of `a` and `b` that differ, as "- " (only in a) and "+ " (only in b), with a line of context around each change. */
134
+ export function lineDiff(a: string, b: string): string[] {
135
+ const x = a.split("\n")
136
+ const y = b.split("\n")
137
+ const n = x.length
138
+ const m = y.length
139
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
140
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
141
+ const ops: string[] = []
142
+ let i = 0
143
+ let j = 0
144
+ while (i < n || j < m) {
145
+ if (i < n && j < m && x[i] === y[j]) {
146
+ ops.push(` ${x[i]}`)
147
+ i++
148
+ j++
149
+ } else if (i < n && (j >= m || lcs[i + 1][j] >= lcs[i][j + 1])) ops.push(`- ${x[i++]}`)
150
+ else ops.push(`+ ${y[j++]}`)
151
+ }
152
+ const keep = new Set<number>()
153
+ ops.forEach((o, k) => {
154
+ if (!o.startsWith(" ")) for (const d of [-1, 0, 1]) keep.add(k + d)
155
+ })
156
+ const out: string[] = []
157
+ let last = -2
158
+ ops.forEach((o, k) => {
159
+ if (!keep.has(k)) return
160
+ if (k > last + 1 && out.length) out.push(" …")
161
+ out.push(o)
162
+ last = k
163
+ })
164
+ return out
165
+ }