@uniqbit/mate-core 0.15.1 → 0.15.2-canary.1
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/package.json +1 -1
- package/src/cli/commands/report/adapter.ts +73 -0
- package/src/cli/commands/report/contract.ts +257 -0
- package/src/cli/commands/report/delivery.ts +79 -0
- package/src/cli/commands/report/index.ts +171 -110
- package/src/cli/commands/report/renderer.ts +228 -73
- package/src/cli/commands/report/types.ts +77 -0
- package/src/cli/main.ts +7 -6
- package/src/cli/plugin-commands.ts +27 -2
- package/src/cli/usage.ts +3 -1
- package/src/templates/root/TEMPLATE_AGENTS.md +1 -1
- package/src/tools/setup/capabilities/openspec.ts +13 -52
- package/src/tools/setup/capabilities/tokensave.ts +37 -4
- package/src/tools/setup/mate.ts +124 -0
- package/src/tools/setup/plugins/gitignore.ts +1 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import path from "node:path";
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
3
2
|
|
|
4
3
|
import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
|
|
5
4
|
import { ensureUnambiguousCompanion } from "../shared/companion-selection";
|
|
@@ -10,149 +9,211 @@ import {
|
|
|
10
9
|
collectTokenSaveSavings,
|
|
11
10
|
mergeResults,
|
|
12
11
|
} from "./collector";
|
|
13
|
-
import { renderJSON, renderMarkdown } from "./renderer";
|
|
14
12
|
import type { CollectorDeps } from "./collector";
|
|
15
|
-
import
|
|
13
|
+
import { reportDataToDocument } from "./adapter";
|
|
14
|
+
import { parseReportDocument, type ReportValidationError } from "./contract";
|
|
15
|
+
import { openReportInBrowser, writeTemporaryReport } from "./delivery";
|
|
16
|
+
import { renderHTML, renderJSON } from "./renderer";
|
|
17
|
+
import type { ReportData, ReportDocument, ReportOptions } from "./types";
|
|
16
18
|
|
|
17
19
|
interface ReportCommandDeps extends CollectorDeps {
|
|
18
20
|
ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
|
|
19
21
|
resolveFrameworkContext?: typeof resolveFrameworkContext;
|
|
22
|
+
writeTemporaryReport?: typeof writeTemporaryReport;
|
|
23
|
+
openReportInBrowser?: typeof openReportInBrowser;
|
|
24
|
+
readInput?: (input: string) => Promise<string>;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
function parseOptions(argv: string[]): ReportOptions {
|
|
23
28
|
let days = 7;
|
|
24
29
|
let json = false;
|
|
30
|
+
let input: string | undefined;
|
|
31
|
+
let daysSpecified = false;
|
|
25
32
|
|
|
26
33
|
for (let i = 0; i < argv.length; i++) {
|
|
27
|
-
if (argv[i] === "--days"
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
if (argv[i] === "--days") {
|
|
35
|
+
daysSpecified = true;
|
|
36
|
+
const value = argv[i + 1];
|
|
37
|
+
if (!value) throw new Error("--days requires a positive integer");
|
|
38
|
+
days = Number.parseInt(value, 10);
|
|
39
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
40
|
+
throw new Error("--days requires a positive integer");
|
|
41
|
+
}
|
|
30
42
|
i++;
|
|
43
|
+
continue;
|
|
31
44
|
}
|
|
32
|
-
if (argv[i] === "--
|
|
33
|
-
|
|
45
|
+
if (argv[i] === "--input") {
|
|
46
|
+
const value = argv[i + 1];
|
|
47
|
+
if (!value) throw new Error("--input requires a JSON file path or - for stdin");
|
|
48
|
+
if (input !== undefined) throw new Error("--input may only be provided once");
|
|
49
|
+
input = value;
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
34
52
|
}
|
|
53
|
+
if (argv[i] === "--json") json = true;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
|
-
|
|
56
|
+
if (input !== undefined && daysSpecified) {
|
|
57
|
+
throw new Error("--days cannot be combined with --input; the document supplies its own period");
|
|
58
|
+
}
|
|
59
|
+
return { days, json, input };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readStdin(): Promise<string> {
|
|
63
|
+
const chunks: Uint8Array[] = [];
|
|
64
|
+
for await (const chunk of process.stdin) {
|
|
65
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
66
|
+
}
|
|
67
|
+
const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
68
|
+
const result = new Uint8Array(size);
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const chunk of chunks) {
|
|
71
|
+
result.set(chunk, offset);
|
|
72
|
+
offset += chunk.byteLength;
|
|
73
|
+
}
|
|
74
|
+
return new TextDecoder().decode(result);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readStructuredInput(input: string, deps: ReportCommandDeps): Promise<string> {
|
|
78
|
+
if (deps.readInput) return deps.readInput(input);
|
|
79
|
+
return input === "-" ? readStdin() : readFile(input, "utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validationMessage(error: unknown): string {
|
|
83
|
+
if (error && typeof error === "object" && "issues" in error) {
|
|
84
|
+
const issues = (error as ReportValidationError).issues;
|
|
85
|
+
return issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ");
|
|
86
|
+
}
|
|
87
|
+
return error instanceof Error ? error.message : String(error);
|
|
38
88
|
}
|
|
39
89
|
|
|
40
90
|
/**
|
|
41
|
-
* @command mate report [--days N] [--json]
|
|
42
|
-
* @description Generates a
|
|
43
|
-
* and savings (via TokenSave/Headroom), merged across working and companion repos.
|
|
44
|
-
* Writes REPORT.md to companion repo by default. Use --json for machine-readable stdout.
|
|
91
|
+
* @command mate report [--days N] [--input FILE|-] [--json]
|
|
92
|
+
* @description Generates a usage report from built-in collectors or a validated JSON document.
|
|
45
93
|
*/
|
|
46
94
|
export async function runReportCommand(
|
|
47
95
|
argv: string[] = [],
|
|
48
96
|
deps: ReportCommandDeps = {},
|
|
49
97
|
): Promise<void> {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
98
|
+
let options: ReportOptions;
|
|
99
|
+
try {
|
|
100
|
+
options = parseOptions(argv);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(`Invalid report options: ${validationMessage(error)}`);
|
|
55
103
|
process.exitCode = 1;
|
|
56
104
|
return;
|
|
57
105
|
}
|
|
58
106
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
repository || contextKind === "companion-root" ? null : await workingRepoStore.load();
|
|
64
|
-
|
|
65
|
-
const workingRepoPath = repository?.path ?? working?.repos[0]?.path ?? process.cwd();
|
|
66
|
-
const companionRepoPath = companionPath;
|
|
67
|
-
const capabilities = config.capabilities ?? [];
|
|
68
|
-
const enabledCapabilities = capabilities.map((c) => c.name);
|
|
69
|
-
const activeAgents = process.env.MATE_ALLOWED_AGENTS
|
|
70
|
-
? process.env.MATE_ALLOWED_AGENTS.split(",")
|
|
71
|
-
.map((a) => a.trim())
|
|
72
|
-
.filter(Boolean)
|
|
73
|
-
: [];
|
|
74
|
-
|
|
75
|
-
const tokensaveEnabled = enabledCapabilities.includes("tokensave");
|
|
76
|
-
const headroomEnabled = enabledCapabilities.includes("headroom");
|
|
77
|
-
const rtkEnabled = enabledCapabilities.includes("rtk");
|
|
78
|
-
|
|
79
|
-
// Collect spending via ccusage (covers all agents: Claude, OpenCode, Codex, etc.)
|
|
80
|
-
const ccusageResult = await collectCcusageSpending(options.days, deps);
|
|
81
|
-
if (ccusageResult.friendlyError) {
|
|
82
|
-
console.warn(ccusageResult.friendlyError);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Collect savings in parallel
|
|
86
|
-
let tokensaveWorking: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
|
|
87
|
-
entry: null,
|
|
88
|
-
status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
|
|
89
|
-
};
|
|
90
|
-
let tokensaveCompanion: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
|
|
91
|
-
entry: null,
|
|
92
|
-
status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
|
|
93
|
-
};
|
|
94
|
-
let rtkResult: Awaited<ReturnType<typeof collectRTKSavings>> = {
|
|
95
|
-
entry: null,
|
|
96
|
-
status: { name: "rtk", enabled: false, status: "not enabled" },
|
|
97
|
-
};
|
|
98
|
-
let headroomResult: Awaited<ReturnType<typeof collectHeadroomSavings>> = {
|
|
99
|
-
entry: null,
|
|
100
|
-
status: { name: "headroom", enabled: false, status: "not enabled" },
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
if (tokensaveEnabled) {
|
|
104
|
-
[tokensaveWorking, tokensaveCompanion] = await Promise.all([
|
|
105
|
-
collectTokenSaveSavings(workingRepoPath, options.days, deps),
|
|
106
|
-
collectTokenSaveSavings(companionRepoPath, options.days, deps),
|
|
107
|
-
]);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (rtkEnabled) {
|
|
111
|
-
rtkResult = await collectRTKSavings(workingRepoPath, deps);
|
|
107
|
+
const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
|
|
108
|
+
if (!(await ensureCompanion(process.cwd()))) {
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
let reportDocument: ReportDocument;
|
|
114
|
+
if (options.input !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
reportDocument = parseReportDocument(await readStructuredInput(options.input, deps));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(`Invalid report input: ${validationMessage(error)}`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const resolveCtx = deps.resolveFrameworkContext ?? resolveFrameworkContext;
|
|
124
|
+
const { companionPath, configStore, workingRepoStore, repository, contextKind } =
|
|
125
|
+
await resolveCtx(process.cwd());
|
|
126
|
+
const config = await configStore.load();
|
|
127
|
+
const working =
|
|
128
|
+
repository || contextKind === "companion-root" ? null : await workingRepoStore.load();
|
|
129
|
+
|
|
130
|
+
const workingRepoPath = repository?.path ?? working?.repos[0]?.path ?? process.cwd();
|
|
131
|
+
const capabilities = config.capabilities ?? [];
|
|
132
|
+
const enabledCapabilities = capabilities.map((c) => c.name);
|
|
133
|
+
const activeAgents = process.env.MATE_ALLOWED_AGENTS
|
|
134
|
+
? process.env.MATE_ALLOWED_AGENTS.split(",")
|
|
135
|
+
.map((a) => a.trim())
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
: [];
|
|
138
|
+
|
|
139
|
+
const tokensaveEnabled = enabledCapabilities.includes("tokensave");
|
|
140
|
+
const headroomEnabled = enabledCapabilities.includes("headroom");
|
|
141
|
+
const rtkEnabled = enabledCapabilities.includes("rtk");
|
|
142
|
+
|
|
143
|
+
const ccusageResult = await collectCcusageSpending(options.days, deps);
|
|
144
|
+
if (ccusageResult.friendlyError) console.warn(ccusageResult.friendlyError);
|
|
145
|
+
|
|
146
|
+
let tokensaveWorking: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
|
|
147
|
+
entry: null,
|
|
148
|
+
status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
|
|
149
|
+
};
|
|
150
|
+
let tokensaveCompanion: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
|
|
151
|
+
entry: null,
|
|
152
|
+
status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
|
|
153
|
+
};
|
|
154
|
+
let rtkResult: Awaited<ReturnType<typeof collectRTKSavings>> = {
|
|
155
|
+
entry: null,
|
|
156
|
+
status: { name: "rtk", enabled: false, status: "not enabled" },
|
|
157
|
+
};
|
|
158
|
+
let headroomResult: Awaited<ReturnType<typeof collectHeadroomSavings>> = {
|
|
159
|
+
entry: null,
|
|
160
|
+
status: { name: "headroom", enabled: false, status: "not enabled" },
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (tokensaveEnabled) {
|
|
164
|
+
[tokensaveWorking, tokensaveCompanion] = await Promise.all([
|
|
165
|
+
collectTokenSaveSavings(workingRepoPath, options.days, deps),
|
|
166
|
+
collectTokenSaveSavings(companionPath, options.days, deps),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
if (rtkEnabled) rtkResult = await collectRTKSavings(workingRepoPath, deps);
|
|
170
|
+
if (headroomEnabled) headroomResult = await collectHeadroomSavings(options.days, deps);
|
|
171
|
+
|
|
172
|
+
const merged = mergeResults(
|
|
173
|
+
ccusageResult.entries,
|
|
174
|
+
tokensaveWorking.entry,
|
|
175
|
+
tokensaveCompanion.entry,
|
|
176
|
+
rtkResult.entry,
|
|
177
|
+
headroomResult.entry,
|
|
178
|
+
);
|
|
179
|
+
const reportData: ReportData = {
|
|
180
|
+
days: options.days,
|
|
181
|
+
generatedAt: new Date().toISOString(),
|
|
182
|
+
workingRepoPath,
|
|
183
|
+
companionRepoPath: companionPath,
|
|
184
|
+
activeAgents,
|
|
185
|
+
enabledCapabilities,
|
|
186
|
+
spending: merged.spending,
|
|
187
|
+
savings: merged.savings,
|
|
188
|
+
toolStatus: [
|
|
189
|
+
ccusageResult.status,
|
|
190
|
+
tokensaveWorking.status,
|
|
191
|
+
rtkResult.status,
|
|
192
|
+
headroomResult.status,
|
|
193
|
+
],
|
|
194
|
+
totalSpending: merged.totalSpending,
|
|
195
|
+
totalSavings: merged.totalSavings,
|
|
196
|
+
netSpend: merged.netSpend,
|
|
197
|
+
};
|
|
198
|
+
reportDocument = reportDataToDocument(reportData);
|
|
116
199
|
}
|
|
117
200
|
|
|
118
|
-
// Merge results
|
|
119
|
-
const merged = mergeResults(
|
|
120
|
-
ccusageResult.entries,
|
|
121
|
-
tokensaveWorking.entry,
|
|
122
|
-
tokensaveCompanion.entry,
|
|
123
|
-
rtkResult.entry,
|
|
124
|
-
headroomResult.entry,
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const allToolStatus = [
|
|
128
|
-
ccusageResult.status,
|
|
129
|
-
tokensaveWorking.status,
|
|
130
|
-
rtkResult.status,
|
|
131
|
-
headroomResult.status,
|
|
132
|
-
];
|
|
133
|
-
|
|
134
|
-
const reportData: ReportData = {
|
|
135
|
-
days: options.days,
|
|
136
|
-
generatedAt: new Date().toISOString(),
|
|
137
|
-
workingRepoPath,
|
|
138
|
-
companionRepoPath,
|
|
139
|
-
activeAgents,
|
|
140
|
-
enabledCapabilities,
|
|
141
|
-
spending: merged.spending,
|
|
142
|
-
savings: merged.savings,
|
|
143
|
-
toolStatus: allToolStatus,
|
|
144
|
-
totalSpending: merged.totalSpending,
|
|
145
|
-
totalSavings: merged.totalSavings,
|
|
146
|
-
netSpend: merged.netSpend,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
201
|
if (options.json) {
|
|
150
|
-
console.log(renderJSON(
|
|
202
|
+
console.log(renderJSON(reportDocument));
|
|
151
203
|
return;
|
|
152
204
|
}
|
|
153
205
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
206
|
+
let reportPath: string | undefined;
|
|
207
|
+
try {
|
|
208
|
+
reportPath = await (deps.writeTemporaryReport ?? writeTemporaryReport)(
|
|
209
|
+
renderHTML(reportDocument),
|
|
210
|
+
);
|
|
211
|
+
await (deps.openReportInBrowser ?? openReportInBrowser)(reportPath);
|
|
212
|
+
console.log(`Report opened from ${reportPath}`);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
const location = reportPath ? ` at ${reportPath}` : "";
|
|
216
|
+
console.warn(`Unable to open HTML report${location}: ${message}`);
|
|
217
|
+
console.log(renderJSON(reportDocument));
|
|
218
|
+
}
|
|
158
219
|
}
|
|
@@ -1,108 +1,263 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { reportDataToDocument } from "./adapter";
|
|
2
|
+
import { validateReportDocument } from "./contract";
|
|
3
|
+
import type {
|
|
4
|
+
ReportData,
|
|
5
|
+
ReportDocument,
|
|
6
|
+
ReportKeyValue,
|
|
7
|
+
ReportSection,
|
|
8
|
+
ReportValue,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
type RenderInput = ReportDocument | ReportData;
|
|
12
|
+
|
|
13
|
+
function isReportDocument(input: RenderInput): input is ReportDocument {
|
|
14
|
+
return "version" in input;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalize(input: RenderInput): ReportDocument {
|
|
18
|
+
return validateReportDocument(isReportDocument(input) ? input : reportDataToDocument(input));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function valueToString(value: ReportValue): string {
|
|
22
|
+
if (value === null) return "N/A";
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
3
25
|
|
|
4
26
|
function renderTable(headers: string[], rows: string[][]): string {
|
|
5
|
-
if (rows.length === 0) {
|
|
6
|
-
return `| ${headers.join(" | ")} |\n`;
|
|
7
|
-
}
|
|
27
|
+
if (rows.length === 0) return `| ${headers.join(" | ")} |`;
|
|
8
28
|
|
|
9
29
|
const widths = headers.map((header, index) =>
|
|
10
30
|
Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)),
|
|
11
31
|
);
|
|
12
|
-
|
|
13
32
|
const renderRow = (row: string[]): string =>
|
|
14
33
|
`| ${row.map((value, index) => value.padEnd(widths[index])).join(" | ")} |`;
|
|
15
|
-
|
|
16
34
|
const divider = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
|
|
17
35
|
return [renderRow(headers), divider, ...rows.map(renderRow)].join("\n");
|
|
18
36
|
}
|
|
19
37
|
|
|
20
|
-
function renderKeyValueTable(rows:
|
|
21
|
-
return renderTable(
|
|
38
|
+
function renderKeyValueTable(rows: ReportKeyValue[]): string {
|
|
39
|
+
return renderTable(
|
|
40
|
+
["Field", "Value"],
|
|
41
|
+
rows.map((row) => [row.label, valueToString(row.value)]),
|
|
42
|
+
);
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
function printSection(title: string, body: string): string {
|
|
25
46
|
return `${title}\n\n${body}\n`;
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
|
|
29
|
-
const
|
|
49
|
+
function emptyTableMessage(title: string): string {
|
|
50
|
+
const normalized = title.toLowerCase();
|
|
51
|
+
return normalized === "spending" || normalized === "savings"
|
|
52
|
+
? `No ${normalized} data available.`
|
|
53
|
+
: "No data available.";
|
|
54
|
+
}
|
|
30
55
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
function sectionMarkdown(section: ReportSection): string {
|
|
57
|
+
switch (section.type) {
|
|
58
|
+
case "metadata":
|
|
59
|
+
case "key-value":
|
|
60
|
+
return renderKeyValueTable(section.items);
|
|
61
|
+
case "metrics":
|
|
62
|
+
return renderTable(
|
|
63
|
+
["Metric", "Value"],
|
|
64
|
+
section.items.map((item) => [item.label, valueToString(item.value)]),
|
|
65
|
+
);
|
|
66
|
+
case "table":
|
|
67
|
+
return section.rows.length
|
|
68
|
+
? renderTable(
|
|
69
|
+
section.columns,
|
|
70
|
+
section.rows.map((row) => row.map(valueToString)),
|
|
71
|
+
)
|
|
72
|
+
: emptyTableMessage(section.title);
|
|
73
|
+
case "statuses":
|
|
74
|
+
return section.items.length
|
|
75
|
+
? renderTable(
|
|
76
|
+
["Status", "Value"],
|
|
77
|
+
section.items.map((item) => [
|
|
78
|
+
item.label,
|
|
79
|
+
item.detail ? `${item.status}: ${item.detail}` : item.status,
|
|
80
|
+
]),
|
|
81
|
+
)
|
|
82
|
+
: "No data available.";
|
|
83
|
+
case "text":
|
|
84
|
+
return section.content;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
40
87
|
|
|
41
|
-
|
|
88
|
+
export function renderMarkdown(input: RenderInput): string {
|
|
89
|
+
const data = normalize(input);
|
|
90
|
+
const sections: string[] = [`# ${data.title}\n`];
|
|
91
|
+
const metadata = [...data.metadata];
|
|
92
|
+
if (data.period) metadata.unshift({ label: "Period", value: data.period });
|
|
93
|
+
if (data.context) metadata.push({ label: "Context", value: data.context });
|
|
94
|
+
sections.push(renderKeyValueTable(metadata));
|
|
42
95
|
sections.push(
|
|
43
96
|
"\n" +
|
|
44
97
|
printSection(
|
|
45
98
|
"## Summary",
|
|
46
|
-
renderKeyValueTable([
|
|
47
|
-
["Total Spending", formatCost(data.totalSpending)],
|
|
48
|
-
["Total Savings", formatCost(data.totalSavings)],
|
|
49
|
-
["Net Spend", formatCost(data.netSpend)],
|
|
50
|
-
]),
|
|
51
|
-
),
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
// Spending
|
|
55
|
-
if (data.spending.length > 0) {
|
|
56
|
-
const spendingRows = data.spending.map((entry) => [
|
|
57
|
-
entry.model,
|
|
58
|
-
formatTokens(entry.inputTokens),
|
|
59
|
-
formatTokens(entry.outputTokens),
|
|
60
|
-
formatTokens(entry.cacheReadTokens),
|
|
61
|
-
formatTokens(entry.cacheWriteTokens),
|
|
62
|
-
formatCost(entry.cost),
|
|
63
|
-
]);
|
|
64
|
-
sections.push(
|
|
65
|
-
printSection(
|
|
66
|
-
"## Spending",
|
|
67
99
|
renderTable(
|
|
68
|
-
["
|
|
69
|
-
|
|
100
|
+
["Metric", "Value"],
|
|
101
|
+
data.summary.map((item) => [item.label, valueToString(item.value)]),
|
|
70
102
|
),
|
|
71
103
|
),
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
sections.push(printSection(
|
|
104
|
+
);
|
|
105
|
+
for (const section of data.sections) {
|
|
106
|
+
sections.push(printSection(`## ${section.title}`, sectionMarkdown(section)));
|
|
75
107
|
}
|
|
108
|
+
return sections.join("\n");
|
|
109
|
+
}
|
|
76
110
|
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
entry.tokensSaved === 0 ? "N/A" : formatTokens(entry.tokensSaved),
|
|
82
|
-
String(entry.calls),
|
|
83
|
-
entry.costSaved === 0 ? "N/A" : formatCost(entry.costSaved),
|
|
84
|
-
entry.efficiency,
|
|
85
|
-
]);
|
|
86
|
-
sections.push(
|
|
87
|
-
printSection(
|
|
88
|
-
"## Savings",
|
|
89
|
-
renderTable(["Tool", "Tokens Saved", "Calls", "Cost Saved", "Efficiency"], savingsRows),
|
|
90
|
-
),
|
|
91
|
-
);
|
|
92
|
-
} else {
|
|
93
|
-
sections.push(printSection("## Savings", "No savings data available."));
|
|
94
|
-
}
|
|
111
|
+
export function renderJSON(input: RenderInput): string {
|
|
112
|
+
if (!isReportDocument(input)) return JSON.stringify(input, null, 2);
|
|
113
|
+
return JSON.stringify(normalize(input), null, 2);
|
|
114
|
+
}
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
function escapeHTML(value: string): string {
|
|
117
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
118
|
+
switch (character) {
|
|
119
|
+
case "&":
|
|
120
|
+
return "&";
|
|
121
|
+
case "<":
|
|
122
|
+
return "<";
|
|
123
|
+
case ">":
|
|
124
|
+
return ">";
|
|
125
|
+
case '"':
|
|
126
|
+
return """;
|
|
127
|
+
default:
|
|
128
|
+
return "'";
|
|
129
|
+
}
|
|
100
130
|
});
|
|
101
|
-
|
|
131
|
+
}
|
|
102
132
|
|
|
103
|
-
|
|
133
|
+
function renderHTMLTable(headers: string[], rows: string[][]): string {
|
|
134
|
+
const headerHTML = headers.map((header) => `<th scope="col">${escapeHTML(header)}</th>`).join("");
|
|
135
|
+
const rowsHTML = rows.length
|
|
136
|
+
? rows
|
|
137
|
+
.map((row) => `<tr>${row.map((value) => `<td>${escapeHTML(value)}</td>`).join("")}</tr>`)
|
|
138
|
+
.join("")
|
|
139
|
+
: "";
|
|
140
|
+
return `<div class="table-wrap"><table><thead><tr>${headerHTML}</tr></thead><tbody>${rowsHTML}</tbody></table></div>`;
|
|
104
141
|
}
|
|
105
142
|
|
|
106
|
-
|
|
107
|
-
return
|
|
143
|
+
function emptyHTML(message: string): string {
|
|
144
|
+
return `<p class="empty">${escapeHTML(message)}</p>`;
|
|
108
145
|
}
|
|
146
|
+
|
|
147
|
+
function renderHTMLKeyValues(items: ReportKeyValue[]): string {
|
|
148
|
+
return items.length
|
|
149
|
+
? renderHTMLTable(
|
|
150
|
+
["Field", "Value"],
|
|
151
|
+
items.map((item) => [item.label, valueToString(item.value)]),
|
|
152
|
+
)
|
|
153
|
+
: emptyHTML("No metadata available.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderHTMLSectionBody(section: ReportSection): string {
|
|
157
|
+
switch (section.type) {
|
|
158
|
+
case "metadata":
|
|
159
|
+
case "key-value":
|
|
160
|
+
return renderHTMLKeyValues(section.items);
|
|
161
|
+
case "metrics":
|
|
162
|
+
return section.items.length
|
|
163
|
+
? renderHTMLTable(
|
|
164
|
+
["Metric", "Value"],
|
|
165
|
+
section.items.map((item) => [item.label, valueToString(item.value)]),
|
|
166
|
+
)
|
|
167
|
+
: emptyHTML("No metrics available.");
|
|
168
|
+
case "table":
|
|
169
|
+
return section.rows.length
|
|
170
|
+
? renderHTMLTable(
|
|
171
|
+
section.columns,
|
|
172
|
+
section.rows.map((row) => row.map(valueToString)),
|
|
173
|
+
)
|
|
174
|
+
: emptyHTML(emptyTableMessage(section.title));
|
|
175
|
+
case "statuses":
|
|
176
|
+
return section.items.length
|
|
177
|
+
? renderHTMLTable(
|
|
178
|
+
["Status", "Value"],
|
|
179
|
+
section.items.map((item) => [
|
|
180
|
+
item.label,
|
|
181
|
+
item.detail ? `${item.status}: ${item.detail}` : item.status,
|
|
182
|
+
]),
|
|
183
|
+
)
|
|
184
|
+
: emptyHTML("No status data available.");
|
|
185
|
+
case "text":
|
|
186
|
+
return section.content
|
|
187
|
+
? `<p class="report-text">${escapeHTML(section.content).replaceAll("\n", "<br>")}</p>`
|
|
188
|
+
: emptyHTML("No text available.");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderHTMLSection(section: ReportSection): string {
|
|
193
|
+
return `<section id="${escapeHTML(section.id)}"><h2>${escapeHTML(section.title)}</h2>${renderHTMLSectionBody(section)}</section>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function renderHTML(input: RenderInput): string {
|
|
197
|
+
const data = normalize(input);
|
|
198
|
+
const legacy = isReportDocument(input) ? undefined : input;
|
|
199
|
+
const metadata = [...data.metadata];
|
|
200
|
+
if (data.period) metadata.unshift({ label: "Period", value: data.period });
|
|
201
|
+
if (data.context) metadata.push({ label: "Context", value: data.context });
|
|
202
|
+
const summary: ReportMetricsSection = {
|
|
203
|
+
id: "summary",
|
|
204
|
+
title: "Summary",
|
|
205
|
+
type: "metrics",
|
|
206
|
+
items: data.summary,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const repositoryAttributes = legacy
|
|
210
|
+
? ` data-working-repository="${escapeHTML(legacy.workingRepoPath)}" data-companion-repository="${escapeHTML(legacy.companionRepoPath)}"`
|
|
211
|
+
: "";
|
|
212
|
+
|
|
213
|
+
return `<!doctype html>
|
|
214
|
+
<html lang="en">
|
|
215
|
+
<head>
|
|
216
|
+
<meta charset="utf-8">
|
|
217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
218
|
+
<title>${escapeHTML(data.title)}</title>
|
|
219
|
+
<style>
|
|
220
|
+
:root { color-scheme: light; font-family: system-ui, sans-serif; background: #f4f6f8; color: #18212b; }
|
|
221
|
+
body { margin: 0; }
|
|
222
|
+
.report { max-width: 1120px; margin: 0 auto; padding: 32px 20px 56px; }
|
|
223
|
+
header, section { background: #fff; border: 1px solid #dce2e8; border-radius: 14px; margin-bottom: 20px; padding: 24px; box-shadow: 0 8px 24px rgb(24 33 43 / 6%); }
|
|
224
|
+
h1, h2 { margin-top: 0; }
|
|
225
|
+
h1 { margin-bottom: 8px; }
|
|
226
|
+
h2 { font-size: 1.15rem; }
|
|
227
|
+
.eyebrow { color: #536273; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
|
228
|
+
.print-control { background: #18212b; border: 0; border-radius: 8px; color: #fff; cursor: pointer; padding: 10px 14px; }
|
|
229
|
+
.table-wrap { overflow-x: auto; }
|
|
230
|
+
table { border-collapse: collapse; min-width: 100%; }
|
|
231
|
+
th, td { border-bottom: 1px solid #e8edf1; padding: 10px 12px; text-align: left; vertical-align: top; }
|
|
232
|
+
th { color: #536273; font-size: .8rem; text-transform: uppercase; }
|
|
233
|
+
.empty { color: #536273; margin: 0; }
|
|
234
|
+
@media (max-width: 640px) { .report { padding: 16px 12px 32px; } header, section { padding: 16px; } }
|
|
235
|
+
@media print {
|
|
236
|
+
:root, body { background: #fff; color: #000; }
|
|
237
|
+
.report { max-width: none; padding: 0; }
|
|
238
|
+
header, section { border: 0; border-radius: 0; box-shadow: none; margin-bottom: 16px; padding: 0; }
|
|
239
|
+
.print-control, .interactive-only { display: none !important; }
|
|
240
|
+
.table-wrap { overflow: visible; }
|
|
241
|
+
table { width: 100%; }
|
|
242
|
+
th, td { color: #000; }
|
|
243
|
+
}
|
|
244
|
+
</style>
|
|
245
|
+
</head>
|
|
246
|
+
<body>
|
|
247
|
+
<main class="report" data-report-version="${data.version}"${repositoryAttributes}>
|
|
248
|
+
<header>
|
|
249
|
+
<p class="eyebrow">Mate report</p>
|
|
250
|
+
<h1>${escapeHTML(data.title)}</h1>
|
|
251
|
+
${renderHTMLKeyValues(metadata)}
|
|
252
|
+
<button class="print-control interactive-only" type="button" onclick="window.print()">Print / Save as PDF</button>
|
|
253
|
+
</header>
|
|
254
|
+
${renderHTMLSection(summary)}
|
|
255
|
+
${data.sections.map(renderHTMLSection).join("\n ")}
|
|
256
|
+
</main>
|
|
257
|
+
</body>
|
|
258
|
+
</html>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
type ReportMetricsSection = Extract<ReportSection, { type: "metrics" }>;
|
|
262
|
+
|
|
263
|
+
export { escapeHTML };
|