@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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
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
|
+
}
|