@capagents/forgeqe 1.1.6 → 1.1.8
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 +18 -8
- package/docs/user-guide.html +19 -10
- package/package.json +1 -1
- package/prompts/brain.md +4 -2
- package/src/brain/index.ts +4 -0
- package/src/capabilities/registry.ts +6 -2
- package/src/cases/api.ts +75 -22
- package/src/code/runtime.ts +3 -0
- package/src/mcp/reporttools.ts +205 -14
- package/src/mcp/server.ts +22 -7
- package/src/mcp/tools.ts +16 -5
- package/src/report/evidence.ts +313 -0
- package/src/report/html.ts +7 -3
- package/src/report/template.html +46 -45
- package/src/tui/App.tsx +22 -9
- package/src/tui/inspector.tsx +76 -25
- package/src/tui/transcript.tsx +19 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence for reports: every tool result of the session, kept as it came back, so a report's tables are built from
|
|
3
|
+
* what the tools returned and not from what the model wrote. A dataset names the calls it comes from (or a read-only
|
|
4
|
+
* query ForgeQE runs itself); its rows are taken from their output, flattened into columns.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type EvidenceEntry = {
|
|
8
|
+
/** E1, E2, …: how a report cites it. */
|
|
9
|
+
id: string
|
|
10
|
+
tool: string
|
|
11
|
+
input: Record<string, unknown>
|
|
12
|
+
output: string
|
|
13
|
+
at: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_ENTRIES = 300
|
|
17
|
+
const MAX_OUTPUT = 4_000_000
|
|
18
|
+
|
|
19
|
+
/** Tools whose results are never data for a report: files, the shell, the agent's own bookkeeping. */
|
|
20
|
+
const NOT_DATA = new Set(["write_report", "read", "edit", "write", "patch", "multiedit", "bash", "glob", "grep", "list", "todowrite", "todoread", "skill", "task", "StructuredOutput"])
|
|
21
|
+
|
|
22
|
+
export class EvidenceLog {
|
|
23
|
+
private entries: EvidenceEntry[] = []
|
|
24
|
+
private n = 0
|
|
25
|
+
|
|
26
|
+
/** Keeps a successful call's result; files, the shell, and empty results are not evidence. */
|
|
27
|
+
record(tool: string, input: Record<string, unknown>, output: string): EvidenceEntry | null {
|
|
28
|
+
if (NOT_DATA.has(tool) || NOT_DATA.has(bareTool(tool)) || !output.trim()) return null
|
|
29
|
+
const entry = { id: `E${++this.n}`, tool, input, output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output, at: new Date().toISOString() }
|
|
30
|
+
this.entries.push(entry)
|
|
31
|
+
if (this.entries.length > MAX_ENTRIES) this.entries.shift()
|
|
32
|
+
return entry
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
all(): EvidenceEntry[] {
|
|
36
|
+
return [...this.entries]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A tool's name without its MCP server prefix: forgeqe_query_api → query_api, azure_devops_wit_my_work_items → wit_my_work_items. */
|
|
41
|
+
export function bareTool(tool: string): string {
|
|
42
|
+
for (const prefix of ["forgeqe_", "azure_devops_", "atlassian_", "confluence_", "github_", "jira_"]) if (tool.startsWith(prefix)) return tool.slice(prefix.length)
|
|
43
|
+
return tool
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Cell = string | number | boolean | null
|
|
47
|
+
export type Row = Record<string, Cell>
|
|
48
|
+
export type ColumnKind = "text" | "number" | "date" | "link"
|
|
49
|
+
export type Column = { key: string; label: string; kind: ColumnKind }
|
|
50
|
+
|
|
51
|
+
/** Field names as people read them; anything else keeps its own name, split into words. */
|
|
52
|
+
const LABELS: Record<string, string> = {
|
|
53
|
+
id: "ID",
|
|
54
|
+
key: "Key",
|
|
55
|
+
teamproject: "Project",
|
|
56
|
+
project: "Project",
|
|
57
|
+
workitemtype: "Type",
|
|
58
|
+
issuetype: "Type",
|
|
59
|
+
type: "Type",
|
|
60
|
+
title: "Title",
|
|
61
|
+
summary: "Title",
|
|
62
|
+
name: "Name",
|
|
63
|
+
state: "State",
|
|
64
|
+
status: "Status",
|
|
65
|
+
assignedto: "Assigned To",
|
|
66
|
+
assignee: "Assigned To",
|
|
67
|
+
priority: "Priority",
|
|
68
|
+
severity: "Severity",
|
|
69
|
+
areapath: "Area",
|
|
70
|
+
area: "Area",
|
|
71
|
+
iterationpath: "Iteration",
|
|
72
|
+
iteration: "Iteration",
|
|
73
|
+
sprint: "Sprint",
|
|
74
|
+
reason: "Reason",
|
|
75
|
+
tags: "Tags",
|
|
76
|
+
labels: "Tags",
|
|
77
|
+
createddate: "Created",
|
|
78
|
+
created: "Created",
|
|
79
|
+
changeddate: "Changed",
|
|
80
|
+
changed: "Changed",
|
|
81
|
+
updated: "Changed",
|
|
82
|
+
resolveddate: "Resolved",
|
|
83
|
+
resolved: "Resolved",
|
|
84
|
+
closeddate: "Closed",
|
|
85
|
+
createdby: "Created By",
|
|
86
|
+
reporter: "Reported By",
|
|
87
|
+
resolution: "Resolution",
|
|
88
|
+
components: "Components",
|
|
89
|
+
fixversions: "Fix Versions",
|
|
90
|
+
result: "Result",
|
|
91
|
+
outcome: "Result",
|
|
92
|
+
buildnumber: "Build",
|
|
93
|
+
sourcebranch: "Branch",
|
|
94
|
+
definition: "Pipeline",
|
|
95
|
+
pipeline: "Pipeline",
|
|
96
|
+
requestedfor: "Requested For",
|
|
97
|
+
starttime: "Started",
|
|
98
|
+
finishtime: "Finished",
|
|
99
|
+
queuetime: "Queued",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Columns a reader filters by first, in this order, when the data has them. */
|
|
103
|
+
const FILTER_FIRST = ["Project", "State", "Status", "Type", "Priority", "Severity", "Assigned To", "Area", "Iteration", "Sprint", "Result", "Pipeline", "Branch", "Reason", "Resolution", "Components"]
|
|
104
|
+
/** Columns read first in the table. */
|
|
105
|
+
const COLUMN_FIRST = ["ID", "Key", "Title", "Name", "Project", "Type", "State", "Status", "Priority", "Severity", "Assigned To", "Result", "Area", "Iteration", "Created", "Changed"]
|
|
106
|
+
/** Fields that are plumbing, not data. */
|
|
107
|
+
const DROP = new Set(["_links", "url", "self", "rev", "commentversionref", "relations", "expand", "avatarurls", "iconurl", "watermark", "boardcolumndone", "nodeid", "authorizedas", "authorizeddate", "reviseddate", "reviseby", "revisedby", "personid", "descriptor", "imageurl", "uniquename"])
|
|
108
|
+
|
|
109
|
+
const isPlain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
|
|
110
|
+
|
|
111
|
+
/** The last part of a dotted field name: System.TeamProject → TeamProject, Microsoft.VSTS.Common.Priority → Priority. */
|
|
112
|
+
function fieldName(k: string): string {
|
|
113
|
+
const last = k.includes(".") ? k.slice(k.lastIndexOf(".") + 1) : k
|
|
114
|
+
return last.replace(/^custom_?/i, "")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function columnLabel(k: string): string {
|
|
118
|
+
const name = fieldName(k)
|
|
119
|
+
const known = LABELS[name.toLowerCase().replace(/[\s_-]/g, "")]
|
|
120
|
+
if (known) return known
|
|
121
|
+
const words = name.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").trim()
|
|
122
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A value as one cell: a person by their name, a status by its name, a list joined. */
|
|
126
|
+
function cell(v: unknown): Cell | undefined {
|
|
127
|
+
if (v === null || v === undefined || v === "") return undefined
|
|
128
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v
|
|
129
|
+
if (Array.isArray(v)) {
|
|
130
|
+
const parts = v.map((x) => cell(x)).filter((x) => x !== undefined && x !== null)
|
|
131
|
+
return parts.length ? parts.join(", ") : undefined
|
|
132
|
+
}
|
|
133
|
+
if (isPlain(v)) {
|
|
134
|
+
for (const k of ["displayName", "name", "value", "key", "title", "label"]) if (typeof v[k] === "string" || typeof v[k] === "number") return v[k] as string | number
|
|
135
|
+
}
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A web page for the item, when its record says where: Azure DevOps, Jira, GitHub, or a plain web URL. */
|
|
140
|
+
function webLink(o: Record<string, unknown>, idValue: Cell | undefined): string | undefined {
|
|
141
|
+
const links = isPlain(o._links) ? o._links : undefined
|
|
142
|
+
const html = links && isPlain(links.html) ? links.html.href : undefined
|
|
143
|
+
if (typeof html === "string") return html
|
|
144
|
+
for (const k of ["html_url", "webUrl", "web_url", "webLink"]) if (typeof o[k] === "string") return o[k] as string
|
|
145
|
+
if (typeof o.url === "string") {
|
|
146
|
+
// https://dev.azure.com/acme/<project id>/_apis/wit/workItems/123 → https://dev.azure.com/acme/_workitems/edit/123
|
|
147
|
+
const ado = /^(https:\/\/[^/]+(?:\/[^/_][^/]*)?)\/(?:[^/]+\/)?_apis\/wit\/workItems\/(\d+)/i.exec(o.url)
|
|
148
|
+
if (ado) return `${ado[1]}/_workitems/edit/${ado[2]}`
|
|
149
|
+
if (!/\/_apis\/|\/rest\//.test(o.url)) return o.url
|
|
150
|
+
}
|
|
151
|
+
if (typeof o.self === "string" && typeof idValue === "string" && /^[A-Z][A-Z0-9_]+-\d+$/.test(idValue)) {
|
|
152
|
+
const m = /^(https?:\/\/[^/]+(?:\/[^/]+)*?)\/rest\/api\//.exec(o.self)
|
|
153
|
+
if (m) return `${m[1]}/browse/${idValue}`
|
|
154
|
+
}
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One record as a row: its own fields and those under `fields` (Azure DevOps, Jira), named for people. */
|
|
159
|
+
export function flattenRow(o: Record<string, unknown>): Row {
|
|
160
|
+
const row: Row = {}
|
|
161
|
+
const put = (k: string, v: unknown) => {
|
|
162
|
+
if (DROP.has(k.toLowerCase()) || DROP.has(fieldName(k).toLowerCase())) return
|
|
163
|
+
const c = cell(v)
|
|
164
|
+
if (c === undefined) return
|
|
165
|
+
const label = columnLabel(k)
|
|
166
|
+
if (!(label in row)) row[label] = c
|
|
167
|
+
}
|
|
168
|
+
for (const [k, v] of Object.entries(o)) if (k !== "fields") put(k, v)
|
|
169
|
+
if (isPlain(o.fields)) for (const [k, v] of Object.entries(o.fields)) put(k, v)
|
|
170
|
+
// Jira's record id is numeric and its key is what people use.
|
|
171
|
+
if ("Key" in row && "ID" in row && typeof o.key === "string") delete row.ID
|
|
172
|
+
const link = webLink(o, row.Key ?? row.ID)
|
|
173
|
+
if (link) row.Link = link
|
|
174
|
+
return row
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The JSON in a tool's output: the whole of it, or the first object or list in its text. */
|
|
178
|
+
export function parseOutput(output: string): unknown {
|
|
179
|
+
const text = output.trim()
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(text)
|
|
182
|
+
} catch {}
|
|
183
|
+
const start = text.search(/[[{]/)
|
|
184
|
+
if (start < 0) return undefined
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(text.slice(start))
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The records in a result: the largest list of objects in it, looked for a few levels down. */
|
|
193
|
+
export function findRecords(value: unknown, depth = 0): Record<string, unknown>[] {
|
|
194
|
+
if (typeof value === "string" && depth === 0) return findRecords(parseOutput(value), 1)
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
const objs = value.filter(isPlain)
|
|
197
|
+
if (objs.length && objs.length >= value.length / 2) return objs
|
|
198
|
+
return []
|
|
199
|
+
}
|
|
200
|
+
if (!isPlain(value) || depth > 4) return []
|
|
201
|
+
// An MCP result wraps its JSON in content[].text.
|
|
202
|
+
if (Array.isArray(value.content) && value.content.every((c) => isPlain(c) && typeof c.text === "string")) {
|
|
203
|
+
return findRecords((value.content as { text: string }[]).map((c) => c.text).join("\n"), 0)
|
|
204
|
+
}
|
|
205
|
+
let best: Record<string, unknown>[] = []
|
|
206
|
+
for (const v of Object.values(value)) {
|
|
207
|
+
const found = findRecords(typeof v === "string" && /^\s*[[{]/.test(v) ? parseOutput(v) : v, depth + 1)
|
|
208
|
+
if (found.length > best.length) best = found
|
|
209
|
+
}
|
|
210
|
+
return best
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The rows in a tool's output; null when it holds no data (text, an error). An empty list is data: nothing matched. */
|
|
214
|
+
export function rowsOf(output: string): Row[] | null {
|
|
215
|
+
const value = parseOutput(output)
|
|
216
|
+
if (value === undefined || typeof value !== "object" || value === null) return null
|
|
217
|
+
const records = findRecords(value, 1)
|
|
218
|
+
if (records.length) return records.map(flattenRow)
|
|
219
|
+
if (Array.isArray(value)) return value.length ? null : []
|
|
220
|
+
// A list that came back empty ({ count: 0, items: [] }) or a single record (one pipeline run).
|
|
221
|
+
const lists = Object.values(value).filter(Array.isArray)
|
|
222
|
+
if (lists.length && lists.every((l) => !l.length)) return []
|
|
223
|
+
return [flattenRow(value as Record<string, unknown>)]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
|
|
227
|
+
|
|
228
|
+
function kindOf(rows: Row[], key: string): ColumnKind {
|
|
229
|
+
const vals = rows.map((r) => r[key]).filter((v) => v !== null && v !== undefined)
|
|
230
|
+
if (!vals.length) return "text"
|
|
231
|
+
if (vals.every((v) => typeof v === "number")) return "number"
|
|
232
|
+
if (vals.every((v) => typeof v === "string" && ISO_DATE.test(v))) return "date"
|
|
233
|
+
if (vals.every((v) => typeof v === "string" && /^https?:\/\//.test(v))) return "link"
|
|
234
|
+
return "text"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The columns to show: those asked for, or the ones people read first and then the rest, up to 12. */
|
|
238
|
+
export function chooseColumns(rows: Row[], asked?: string[]): Column[] {
|
|
239
|
+
const present: string[] = []
|
|
240
|
+
for (const r of rows) for (const k of Object.keys(r)) if (!present.includes(k)) present.push(k)
|
|
241
|
+
const find = (name: string) => present.find((k) => k.toLowerCase() === name.toLowerCase()) || present.find((k) => k.toLowerCase() === columnLabel(name).toLowerCase())
|
|
242
|
+
let keys: string[]
|
|
243
|
+
if (asked?.length) {
|
|
244
|
+
keys = asked.map(find).filter((k): k is string => !!k)
|
|
245
|
+
if (!keys.length) throw new Error(`None of the columns ${asked.join(", ")} is in the data. Its columns: ${present.join(", ")}.`)
|
|
246
|
+
} else {
|
|
247
|
+
// A column with the same value in every row (Type: Bug) says nothing in a table; the filters and counts still have it.
|
|
248
|
+
const varies = (k: string) => rows.length < 2 || ["ID", "Key", "Title", "Name"].includes(k) || new Set(rows.map((r) => r[k] ?? null)).size > 1
|
|
249
|
+
const first = COLUMN_FIRST.filter((k) => present.includes(k) && varies(k))
|
|
250
|
+
const rest = present.filter((k) => k !== "Link" && !first.includes(k) && varies(k) && rows.some((r) => typeof r[k] !== "string" || String(r[k]).length <= 200))
|
|
251
|
+
keys = [...first, ...rest].slice(0, 10)
|
|
252
|
+
}
|
|
253
|
+
if (present.includes("Link") && !keys.includes("Link")) keys.push("Link")
|
|
254
|
+
return keys.map((key) => ({ key, label: key, kind: key === "Link" ? "link" : kindOf(rows, key) }))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Columns worth a filter: those asked for, or the usual ones (project, state, priority, …) and other short lists of values. */
|
|
258
|
+
export function chooseFilters(rows: Row[], columns: Column[], asked?: string[]): string[] {
|
|
259
|
+
const distinct = (k: string) => new Set(rows.map((r) => r[k]).filter((v) => v !== null && v !== undefined && v !== "")).size
|
|
260
|
+
const text = columns.filter((c) => c.kind === "text").map((c) => c.key)
|
|
261
|
+
if (asked?.length) return asked.map((a) => columns.find((c) => c.key.toLowerCase() === a.toLowerCase() || c.key.toLowerCase() === columnLabel(a).toLowerCase())?.key).filter((k): k is string => !!k)
|
|
262
|
+
// Priority and severity are numbers in Azure DevOps, and still categories.
|
|
263
|
+
const known = FILTER_FIRST.filter((k) => columns.some((c) => c.key === k && c.kind !== "date" && c.kind !== "link") && distinct(k) >= 2 && distinct(k) <= 60)
|
|
264
|
+
const other = text.filter((k) => !known.includes(k) && !["ID", "Key", "Title", "Name"].includes(k)).filter((k) => {
|
|
265
|
+
const n = distinct(k)
|
|
266
|
+
return n >= 2 && n <= 15 && n <= rows.length / 2
|
|
267
|
+
})
|
|
268
|
+
return [...known, ...other].slice(0, 6)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Rows that are the same record (the same ID or key) from more than one call, kept once. */
|
|
272
|
+
export function dedupe(rows: Row[]): Row[] {
|
|
273
|
+
const seen = new Set<string>()
|
|
274
|
+
return rows.filter((r) => {
|
|
275
|
+
const id = r.Key ?? r.ID
|
|
276
|
+
if (id === undefined || id === null) return true
|
|
277
|
+
const k = String(id)
|
|
278
|
+
if (seen.has(k)) return false
|
|
279
|
+
seen.add(k)
|
|
280
|
+
return true
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** What a call was, in one line, for the list of sources. */
|
|
285
|
+
export function describeCall(e: Pick<EvidenceEntry, "tool" | "input">): string {
|
|
286
|
+
const i = e.input
|
|
287
|
+
const what = [i.wiql, i.jql, i.query, i.path, i.project, i.ids, i.searchText, i.title].find((v) => (typeof v === "string" && v.trim()) || Array.isArray(v))
|
|
288
|
+
// A long list of ids as its count and ends: "42 ids: 1000, 1001, … 1041".
|
|
289
|
+
const text = Array.isArray(what) ? (what.length > 6 ? `${what.length} ids: ${what.slice(0, 2).join(", ")}, … ${what.at(-1)}` : what.join(", ")) : String(what || "")
|
|
290
|
+
const flat = text.replace(/\s+/g, " ").trim()
|
|
291
|
+
return `${e.tool}${flat ? `: ${flat.length > 160 ? `${flat.slice(0, 159)}…` : flat}` : ""}`
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The evidence a dataset names: an id (E3), or every successful call of a tool, optionally those whose input mentions `match`. */
|
|
295
|
+
export function pickEvidence(log: EvidenceEntry[], from: string, match?: string): EvidenceEntry[] {
|
|
296
|
+
const ids = from.split(/[\s,]+/).filter(Boolean)
|
|
297
|
+
if (ids.length && ids.every((x) => /^E\d+$/i.test(x))) return log.filter((e) => ids.some((x) => x.toUpperCase() === e.id))
|
|
298
|
+
const want = bareTool(from)
|
|
299
|
+
const calls = log.filter((e) => e.tool === from || bareTool(e.tool) === want)
|
|
300
|
+
return match ? calls.filter((e) => JSON.stringify(e.input).toLowerCase().includes(match.toLowerCase())) : calls
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The calls a report could use, for the error that says none was found. */
|
|
304
|
+
export function evidenceList(log: EvidenceEntry[]): string {
|
|
305
|
+
if (!log.length) return "No tool has returned data in this session yet."
|
|
306
|
+
return log
|
|
307
|
+
.slice(-25)
|
|
308
|
+
.map((e) => {
|
|
309
|
+
const rows = rowsOf(e.output)
|
|
310
|
+
return `${e.id} ${describeCall(e)} (${rows ? `${rows.length} rows` : "no data"})`
|
|
311
|
+
})
|
|
312
|
+
.join("\n")
|
|
313
|
+
}
|
package/src/report/html.ts
CHANGED
|
@@ -87,11 +87,15 @@ const TEMPLATE = new URL("./template.html", import.meta.url)
|
|
|
87
87
|
|
|
88
88
|
/** A self-contained report page: the report-ui app with this run's data (and screenshots) embedded. */
|
|
89
89
|
export function renderReport(p: ReportInput, opts?: DataOptions): string {
|
|
90
|
-
|
|
90
|
+
return reportPage(p.title, buildReportData({ kind: p.kind || "suite", ...p }, opts))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The report-ui app with `data` embedded: a run, a suite, or a data report written by write_report. */
|
|
94
|
+
export function reportPage(title: string, data: unknown): string {
|
|
91
95
|
const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")
|
|
92
|
-
const
|
|
96
|
+
const safe = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
93
97
|
return readFileSync(TEMPLATE, "utf8")
|
|
94
|
-
.replace(/<title>[^<]*<\/title>/, () => `<title>${
|
|
98
|
+
.replace(/<title>[^<]*<\/title>/, () => `<title>${safe}</title>`)
|
|
95
99
|
.replace("<head>", () => `<head>\n<script>window.__FORGEQE_REPORT__=${json}</script>`)
|
|
96
100
|
}
|
|
97
101
|
|