@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
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
|
+
}
|