@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
package/src/update.ts ADDED
@@ -0,0 +1,311 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
3
+ import path from "node:path"
4
+ import { packageVersion } from "./tui/theme.ts"
5
+
6
+ export const NPM_PACKAGE = "@capagents/forgeqe"
7
+ export const PYPI_PACKAGE = "forgeqe-cli"
8
+ const NPM_REGISTRY = "https://registry.npmjs.org"
9
+ const PYPI = "https://pypi.org/pypi"
10
+
11
+ export type InstallKind = "bun" | "npm" | "pip"
12
+
13
+ export interface Install {
14
+ kind: InstallKind
15
+ /** The `forgeqe` found on PATH. */
16
+ bin: string
17
+ /** What it resolves to (the package's entry, or the pip script). */
18
+ target: string
19
+ version: string
20
+ /** The Python that owns a pip install. */
21
+ python?: string
22
+ }
23
+
24
+ export interface UpdateDeps {
25
+ fetch: typeof fetch
26
+ run: (cmd: string, args: string[]) => { status: number | null; stdout: string; stderr: string }
27
+ env: NodeJS.ProcessEnv
28
+ platform: NodeJS.Platform
29
+ sleep: (ms: number) => Promise<void>
30
+ now: () => number
31
+ }
32
+
33
+ export function defaultDeps(): UpdateDeps {
34
+ return {
35
+ fetch,
36
+ run: (cmd, args) => {
37
+ const r = spawnSync(cmd, args, { encoding: "utf8" })
38
+ return { status: r.error ? null : r.status, stdout: r.stdout || "", stderr: r.stderr || (r.error ? String(r.error.message) : "") }
39
+ },
40
+ env: process.env,
41
+ platform: process.platform,
42
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
43
+ now: () => Date.now(),
44
+ }
45
+ }
46
+
47
+ /** Newest first; prerelease tags sort before their release. */
48
+ export function compareVersions(a: string, b: string): number {
49
+ const parse = (v: string) => {
50
+ const [core, pre = ""] = v.replace(/^v/, "").split("-", 2)
51
+ return { parts: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre }
52
+ }
53
+ const x = parse(a)
54
+ const y = parse(b)
55
+ for (let i = 0; i < Math.max(x.parts.length, y.parts.length); i++) {
56
+ const d = (x.parts[i] || 0) - (y.parts[i] || 0)
57
+ if (d) return d
58
+ }
59
+ if (x.pre === y.pre) return 0
60
+ if (!x.pre) return 1
61
+ if (!y.pre) return -1
62
+ return x.pre < y.pre ? -1 : 1
63
+ }
64
+
65
+ const NO_CACHE = { "Cache-Control": "no-cache", Pragma: "no-cache" }
66
+
67
+ /** The registry's `latest` tag, asked past the CDN's cached copy. */
68
+ export async function latestVersion(deps: UpdateDeps): Promise<string> {
69
+ const res = await deps.fetch(`${NPM_REGISTRY}/${NPM_PACKAGE}?t=${deps.now()}`, { headers: { ...NO_CACHE, Accept: "application/json" } })
70
+ if (!res.ok) throw new Error(`npm registry answered ${res.status} for ${NPM_PACKAGE}`)
71
+ const body = (await res.json()) as { "dist-tags"?: { latest?: string } }
72
+ const latest = body["dist-tags"]?.latest
73
+ if (!latest) throw new Error(`npm registry has no latest version of ${NPM_PACKAGE}`)
74
+ return latest
75
+ }
76
+
77
+ /**
78
+ * Whether installers can fetch this version yet: the registry lists it and serves its tarball.
79
+ * Right after a publish the registry can answer with the old version list, or 404 the tarball, for several minutes.
80
+ */
81
+ export async function npmReady(version: string, deps: UpdateDeps): Promise<boolean> {
82
+ try {
83
+ const meta = await deps.fetch(`${NPM_REGISTRY}/${NPM_PACKAGE}/${version}?t=${deps.now()}`, { headers: { ...NO_CACHE, Accept: "application/json" } })
84
+ if (!meta.ok) return false
85
+ const tarball = `${NPM_REGISTRY}/${NPM_PACKAGE}/-/${NPM_PACKAGE.split("/")[1]}-${version}.tgz?t=${deps.now()}`
86
+ const res = await deps.fetch(tarball, { method: "HEAD", headers: NO_CACHE })
87
+ return res.ok
88
+ } catch {
89
+ return false
90
+ }
91
+ }
92
+
93
+ export async function pypiReady(version: string, deps: UpdateDeps): Promise<boolean> {
94
+ try {
95
+ const res = await deps.fetch(`${PYPI}/${PYPI_PACKAGE}/${version}/json?t=${deps.now()}`, { headers: NO_CACHE })
96
+ return res.ok
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ /** Polls `ready` until it holds or `timeoutMs` passes; true when it held. */
103
+ export async function waitUntil(
104
+ ready: () => Promise<boolean>,
105
+ deps: UpdateDeps,
106
+ opts: { timeoutMs: number; everyMs?: number; onWait?: (waitedMs: number) => void },
107
+ ): Promise<boolean> {
108
+ const start = deps.now()
109
+ const every = opts.everyMs ?? 15_000
110
+ for (;;) {
111
+ if (await ready()) return true
112
+ const waited = deps.now() - start
113
+ if (waited + every > opts.timeoutMs) return false
114
+ opts.onWait?.(waited)
115
+ await deps.sleep(every)
116
+ }
117
+ }
118
+
119
+ function isFile(p: string): boolean {
120
+ try {
121
+ return statSync(p).isFile()
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ function packageDirOf(file: string): string | undefined {
128
+ let dir = path.dirname(file)
129
+ for (let i = 0; i < 6; i++) {
130
+ const pkg = path.join(dir, "package.json")
131
+ if (existsSync(pkg)) {
132
+ try {
133
+ if (JSON.parse(readFileSync(pkg, "utf8")).name === NPM_PACKAGE) return dir
134
+ } catch {
135
+ // not ours
136
+ }
137
+ }
138
+ const up = path.dirname(dir)
139
+ if (up === dir) break
140
+ dir = up
141
+ }
142
+ return undefined
143
+ }
144
+
145
+ function pythonOfScript(script: string, platform: NodeJS.Platform): string | undefined {
146
+ if (platform === "win32") {
147
+ const dir = path.dirname(script)
148
+ if (path.basename(dir).toLowerCase() !== "scripts") return undefined
149
+ const python = path.join(path.dirname(dir), "python.exe")
150
+ return existsSync(python) ? python : undefined
151
+ }
152
+ let first = ""
153
+ try {
154
+ first = readFileSync(script, "utf8").split("\n", 1)[0]
155
+ } catch {
156
+ return undefined
157
+ }
158
+ const m = first.match(/^#!\s*(\S*python[\d.]*)\s*$/)
159
+ return m ? m[1] : undefined
160
+ }
161
+
162
+ /** Every `forgeqe` on PATH, in PATH order, and how each was installed. The first one is what the shell runs. */
163
+ export function findInstalls(deps: UpdateDeps): Install[] {
164
+ const exe = deps.platform === "win32" ? ["forgeqe.exe", "forgeqe.cmd", "forgeqe"] : ["forgeqe"]
165
+ const sep = deps.platform === "win32" ? ";" : ":"
166
+ const seen = new Set<string>()
167
+ const installs: Install[] = []
168
+ for (const dir of (deps.env.PATH || "").split(sep).filter(Boolean)) {
169
+ for (const name of exe) {
170
+ const bin = path.join(dir, name)
171
+ if (!isFile(bin)) continue
172
+ let target = bin
173
+ if (/[\\/]\.pyenv[\\/]shims$/.test(dir)) {
174
+ const r = deps.run("pyenv", ["which", "forgeqe"])
175
+ if (r.status !== 0 || !r.stdout.trim()) continue
176
+ target = r.stdout.trim()
177
+ }
178
+ try {
179
+ target = realpathSync(target)
180
+ } catch {
181
+ continue
182
+ }
183
+ if (seen.has(target)) continue
184
+ seen.add(target)
185
+ const pkgDir = packageDirOf(target)
186
+ if (pkgDir) {
187
+ let version = ""
188
+ try {
189
+ version = String(JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version || "")
190
+ } catch {
191
+ // leave it blank
192
+ }
193
+ const kind: InstallKind = /[\\/]\.bun[\\/]/.test(pkgDir) ? "bun" : "npm"
194
+ installs.push({ kind, bin, target, version })
195
+ continue
196
+ }
197
+ const python = pythonOfScript(target, deps.platform)
198
+ if (!python) continue
199
+ const r = deps.run(python, ["-c", "import forgeqe_cli; print(forgeqe_cli.__version__)"])
200
+ if (r.status !== 0) continue
201
+ installs.push({ kind: "pip", bin, target, version: r.stdout.trim(), python })
202
+ }
203
+ }
204
+ return installs
205
+ }
206
+
207
+ function bunCommand(env: NodeJS.ProcessEnv): string {
208
+ const self = path.basename(process.execPath).toLowerCase()
209
+ if (self.startsWith("bun")) return process.execPath
210
+ const home = env.HOME || env.USERPROFILE
211
+ const bundled = home ? path.join(home, ".bun", "bin", process.platform === "win32" ? "bun.exe" : "bun") : ""
212
+ return bundled && existsSync(bundled) ? bundled : "bun"
213
+ }
214
+
215
+ /** The commands that move one install to `version`; each skips the installer's cached copy of the version list. */
216
+ export function updateCommands(install: Install, version: string, env: NodeJS.ProcessEnv): Array<[string, string[]]> {
217
+ switch (install.kind) {
218
+ case "bun": {
219
+ const bun = bunCommand(env)
220
+ return [
221
+ [bun, ["pm", "cache", "rm"]],
222
+ [bun, ["add", "-g", `${NPM_PACKAGE}@${version}`, "--no-cache"]],
223
+ ]
224
+ }
225
+ case "npm":
226
+ return [["npm", ["install", "-g", `${NPM_PACKAGE}@${version}`, "--prefer-online"]]]
227
+ case "pip":
228
+ return [[install.python || "python3", ["-m", "pip", "install", "--upgrade", "--no-cache-dir", `${PYPI_PACKAGE}==${version}`]]]
229
+ }
230
+ }
231
+
232
+ export function commandLine([cmd, args]: [string, string[]]): string {
233
+ return [cmd, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")
234
+ }
235
+
236
+ export interface UpdateOptions {
237
+ /** A version to install instead of the latest. */
238
+ version?: string
239
+ /** Only report what would change. */
240
+ check?: boolean
241
+ /** How long to wait for the registries to serve a new release, in minutes. */
242
+ waitMinutes?: number
243
+ log?: (text: string) => void
244
+ }
245
+
246
+ export async function update(opts: UpdateOptions = {}, deps: UpdateDeps = defaultDeps()): Promise<number> {
247
+ const log = opts.log || ((text: string) => console.log(text))
248
+ const installs = findInstalls(deps)
249
+ if (!installs.length) {
250
+ log(`No forgeqe install found on PATH (running ${packageVersion()} from ${process.argv[1] || "a checkout"}).`)
251
+ log(`Install it with: bun add -g ${NPM_PACKAGE}`)
252
+ return 1
253
+ }
254
+ const target = opts.version || (await latestVersion(deps))
255
+ for (const [i, inst] of installs.entries()) {
256
+ const state = !inst.version ? "version unknown" : compareVersions(inst.version, target) >= 0 ? "up to date" : `→ ${target}`
257
+ log(`${i === 0 ? "runs " : "also "} ${inst.kind.padEnd(3)} ${(inst.version || "?").padEnd(8)} ${state.padEnd(16)} ${inst.bin}`)
258
+ }
259
+ const stale = installs.filter((inst) => !inst.version || compareVersions(inst.version, target) < 0)
260
+ if (!stale.length) {
261
+ log(`\nforgeqe ${target} is the latest; nothing to update.`)
262
+ return 0
263
+ }
264
+ if (opts.check) {
265
+ log(`\nforgeqe update installs ${target}:`)
266
+ for (const inst of stale) for (const c of updateCommands(inst, target, deps.env)) log(` ${commandLine(c)}`)
267
+ return 0
268
+ }
269
+
270
+ const timeoutMs = Math.max(0, opts.waitMinutes ?? 20) * 60_000
271
+ const onWait = (what: string) => (waited: number) =>
272
+ log(` ${what} ${target} is published but not served yet (registries take a few minutes); waited ${Math.round(waited / 1000)}s`)
273
+ if (stale.some((inst) => inst.kind !== "pip")) {
274
+ if (!(await waitUntil(() => npmReady(target, deps), deps, { timeoutMs, onWait: onWait("npm") }))) {
275
+ log(`\nnpm does not serve ${NPM_PACKAGE}@${target} yet. Run forgeqe update again in a few minutes.`)
276
+ return 1
277
+ }
278
+ }
279
+ if (stale.some((inst) => inst.kind === "pip")) {
280
+ if (!(await waitUntil(() => pypiReady(target, deps), deps, { timeoutMs, onWait: onWait("PyPI") }))) {
281
+ log(`\nPyPI does not serve ${PYPI_PACKAGE}==${target} yet. Run forgeqe update again in a few minutes.`)
282
+ return 1
283
+ }
284
+ }
285
+
286
+ let failed = 0
287
+ for (const inst of stale) {
288
+ log(`\nUpdating the ${inst.kind} install (${inst.bin})`)
289
+ for (const c of updateCommands(inst, target, deps.env)) {
290
+ log(` $ ${commandLine(c)}`)
291
+ const r = deps.run(c[0], c[1])
292
+ if (r.status !== 0) {
293
+ const cacheStep = c[1][0] === "pm" && c[1][1] === "cache"
294
+ if (cacheStep) continue
295
+ failed++
296
+ log((r.stderr || r.stdout).trim().split("\n").slice(-5).map((l) => ` ${l}`).join("\n"))
297
+ break
298
+ }
299
+ }
300
+ }
301
+ const after = findInstalls(deps)
302
+ log("")
303
+ for (const inst of after) log(`${inst.kind.padEnd(3)} ${(inst.version || "?").padEnd(8)} ${inst.bin}`)
304
+ const behind = after.filter((inst) => !inst.version || compareVersions(inst.version, target) < 0)
305
+ if (failed || behind.length) {
306
+ log(`\n${behind.length || failed} install(s) are still behind ${target}; the output above says why.`)
307
+ return 1
308
+ }
309
+ log(`\nforgeqe ${target} is installed. Open a new terminal (or run hash -r) if the shell still finds the old one.`)
310
+ return 0
311
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { Usage } from "./types.ts"
2
+ import { formatTokens } from "./tui/theme.ts"
3
+
4
+ /**
5
+ * Tokens and what they cost. `input` counts every prompt token, `cached` the part of it the provider served from its
6
+ * prompt cache (billed at `price.cachedInput` when the profile sets it). A meter records every model session's usage
7
+ * by role, so each request and job can say what it used and cost.
8
+ */
9
+
10
+ export type Price = {
11
+ input: number
12
+ output: number
13
+ currency: string
14
+ cachedInput?: number
15
+ /** Anthropic-style prompt-cache writes, per million; without it they cost the input price. */
16
+ cacheWrite?: number
17
+ /** Where the price came from when not all of it is from llms.json (a list price, or a list's cache discount). */
18
+ note?: string
19
+ /** Part of the price is derived (a list's cache discount applied to a price from llms.json). */
20
+ estimate?: boolean
21
+ }
22
+
23
+ export type Cost = {
24
+ amount: number
25
+ currency: string
26
+ /** Cached input billed at the full input rate: no cached price is known, so the amount is an upper bound. */
27
+ cachedAtFullRate?: boolean
28
+ /** Where the price came from, when not from llms.json ("list price from models.dev, 2026-10-06"). */
29
+ note?: string
30
+ /** Part of the price is derived, so the amount is an estimate. */
31
+ estimate?: boolean
32
+ }
33
+
34
+ export const NO_USAGE: Usage = { input: 0, output: 0 }
35
+
36
+ export function addUsage(a: Usage, b: Usage | undefined): Usage {
37
+ if (!b) return { ...a }
38
+ const cached = (a.cached || 0) + (b.cached || 0)
39
+ const cacheWrite = (a.cacheWrite || 0) + (b.cacheWrite || 0)
40
+ return { input: a.input + b.input, output: a.output + b.output, ...(cached ? { cached } : {}), ...(cacheWrite ? { cacheWrite } : {}) }
41
+ }
42
+
43
+ /** What `usage` costs at `price` (per million tokens); undefined without a price. */
44
+ export function costOf(usage: Usage | undefined, price: Price | undefined): Cost | undefined {
45
+ if (!usage || !price) return undefined
46
+ const cached = Math.min(usage.cached || 0, usage.input)
47
+ const written = Math.min(usage.cacheWrite || 0, usage.input - cached)
48
+ const amount =
49
+ ((usage.input - cached - written) * price.input + cached * (price.cachedInput ?? price.input) + written * (price.cacheWrite ?? price.input) + usage.output * price.output) /
50
+ 1_000_000
51
+ return {
52
+ amount,
53
+ currency: price.currency,
54
+ ...(cached && price.cachedInput === undefined ? { cachedAtFullRate: true } : {}),
55
+ ...(price.note ? { note: price.note } : {}),
56
+ ...(price.estimate && cached ? { estimate: true } : {}),
57
+ }
58
+ }
59
+
60
+ export function addCost(a: Cost | undefined, b: Cost | undefined): Cost | undefined {
61
+ if (!a) return b
62
+ if (!b) return a
63
+ if (a.currency !== b.currency) return a
64
+ const note = a.note || b.note
65
+ return {
66
+ amount: a.amount + b.amount,
67
+ currency: a.currency,
68
+ ...(a.cachedAtFullRate || b.cachedAtFullRate ? { cachedAtFullRate: true } : {}),
69
+ ...(note ? { note } : {}),
70
+ ...(a.estimate || b.estimate ? { estimate: true } : {}),
71
+ }
72
+ }
73
+
74
+ /** US$1.42, £0.07, $0.0031 for amounts under a cent. */
75
+ export function formatCost(c: Cost): string {
76
+ const digits = c.amount > 0 && c.amount < 0.01 ? 4 : 2
77
+ try {
78
+ return new Intl.NumberFormat("en", { style: "currency", currency: c.currency, minimumFractionDigits: digits, maximumFractionDigits: digits }).format(c.amount)
79
+ } catch {
80
+ return `${c.amount.toFixed(digits)} ${c.currency}`
81
+ }
82
+ }
83
+
84
+ /** "1.2M tokens (1.1M in, 980K of it cached · 36K out) · US$1.42": what one job or request used and cost. */
85
+ export function usageLine(usage: Usage, cost?: Cost | null): string {
86
+ const total = usage.input + usage.output
87
+ const cached = usage.cached ? `, ${formatTokens(usage.cached)} of it cached` : ""
88
+ const tokens = `${formatTokens(total)} tokens (${formatTokens(usage.input)} in${cached} · ${formatTokens(usage.output)} out)`
89
+ if (!cost) return `${tokens} · cost unknown: add a price to the profile in llms.json`
90
+ // Honest about what the amount is: exact, at list price, an estimate, or an upper bound.
91
+ if (cost.cachedAtFullRate) return `${tokens} · up to ${formatCost(cost)} (cached input at the full input rate: no cached price is known for this model; set price.cached_input in llms.json)`
92
+ if (cost.estimate) return `${tokens} · ≈${formatCost(cost)} (${cost.note || "estimated"}; set price.cached_input in llms.json to be exact)`
93
+ if (cost.note) return `${tokens} · ${formatCost(cost)} (${cost.note})`
94
+ return `${tokens} · ${formatCost(cost)}`
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------------------------
98
+ // The meter: every model session reports here, by role
99
+ // ---------------------------------------------------------------------------------------------
100
+
101
+ export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge"
102
+
103
+ type Entry = { usage: Usage; cost?: Cost; priced: boolean }
104
+
105
+ const meter = new Map<Role, Entry>()
106
+
107
+ /** Add `usage` (a delta, not a running total) for `role`, priced at `price` when known. */
108
+ export function recordUsage(role: Role, usage: Usage, price: Price | undefined): void {
109
+ if (!usage.input && !usage.output) return
110
+ const e = meter.get(role) || { usage: { ...NO_USAGE }, priced: true }
111
+ e.usage = addUsage(e.usage, usage)
112
+ const cost = costOf(usage, price)
113
+ e.cost = addCost(e.cost, cost)
114
+ e.priced = e.priced && Boolean(cost)
115
+ meter.set(role, e)
116
+ }
117
+
118
+ export type MeterSnapshot = Map<Role, Entry>
119
+
120
+ export function meterSnapshot(): MeterSnapshot {
121
+ return new Map([...meter].map(([k, v]) => [k, { usage: { ...v.usage }, cost: v.cost && { ...v.cost }, priced: v.priced }]))
122
+ }
123
+
124
+ /** What each role used and cost since `before`, and the total. */
125
+ export function meterSince(before: MeterSnapshot): { total: Usage; cost?: Cost; priced: boolean; roles: { role: Role; usage: Usage; cost?: Cost }[] } {
126
+ const roles: { role: Role; usage: Usage; cost?: Cost }[] = []
127
+ let total: Usage = { ...NO_USAGE }
128
+ let cost: Cost | undefined
129
+ let priced = true
130
+ for (const [role, now] of meter) {
131
+ const was = before.get(role)
132
+ const usage: Usage = {
133
+ input: now.usage.input - (was?.usage.input || 0),
134
+ output: now.usage.output - (was?.usage.output || 0),
135
+ ...(now.usage.cached || was?.usage.cached ? { cached: (now.usage.cached || 0) - (was?.usage.cached || 0) } : {}),
136
+ }
137
+ if (!usage.input && !usage.output) continue
138
+ const c = now.cost ? { ...now.cost, amount: now.cost.amount - (was?.cost?.amount || 0) } : undefined
139
+ roles.push({ role, usage, cost: c })
140
+ total = addUsage(total, usage)
141
+ cost = addCost(cost, c)
142
+ priced = priced && now.priced
143
+ }
144
+ return { total, cost: priced ? cost : undefined, priced, roles }
145
+ }
146
+
147
+ /** "This request used 1.2M tokens (…) · US$1.42 — ForgeQE's agent 40K · US$0.11, Scribe 1.1M · US$1.25, …" */
148
+ export function requestLine(before: MeterSnapshot, label = "This request used"): string {
149
+ const m = meterSince(before)
150
+ if (!m.roles.length) return ""
151
+ const parts = m.roles.map((r) => `${r.role} ${formatTokens(r.usage.input + r.usage.output)}${r.cost ? ` · ${formatCost(r.cost)}` : ""}`)
152
+ return `${label} ${usageLine(m.total, m.cost)}${m.roles.length > 1 ? ` — ${parts.join(", ")}` : ""}`
153
+ }
154
+
155
+ /** Tests: forget everything recorded so far. */
156
+ export function resetMeter(): void {
157
+ meter.clear()
158
+ }