@eat-pray-ai/wingman 0.1.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/.github/dependabot.yml +11 -0
- package/.github/workflows/codeql.yml +103 -0
- package/.github/workflows/publish.yml +23 -0
- package/.github/workflows/release.yml +52 -0
- package/.github/workflows/test.yml +32 -0
- package/.idea/workspace.xml +125 -0
- package/AGENTS.md +34 -0
- package/README.md +145 -0
- package/bin/wingman.ts +2 -0
- package/dist/bin/wingman.mjs +3 -0
- package/dist/src/cli.mjs +2229 -0
- package/dist/src/cli.mjs.map +1 -0
- package/docs/AGENTS.md +172 -0
- package/docs/resume.yaml +68 -0
- package/docs/wingman.pdf +2544 -1
- package/docs/wingman.png +0 -0
- package/docs/wingman.svg +376 -0
- package/package.json +51 -0
- package/scripts/generate-demo.ts +265 -0
- package/src/AGENTS.md +50 -0
- package/src/agents/AGENTS.md +52 -0
- package/src/agents/claude-code.ts +160 -0
- package/src/agents/codex.ts +174 -0
- package/src/agents/gemini-cli.ts +100 -0
- package/src/agents/opencode.ts +117 -0
- package/src/agents/registry.ts +12 -0
- package/src/agents/skills.ts +51 -0
- package/src/aggregator.ts +142 -0
- package/src/cli.ts +194 -0
- package/src/inventory.ts +84 -0
- package/src/pricing/AGENTS.md +36 -0
- package/src/pricing/__tests__/model-info.test.ts +135 -0
- package/src/pricing/engine.ts +86 -0
- package/src/pricing/models-dev.ts +253 -0
- package/src/resume/AGENTS.md +34 -0
- package/src/resume/__tests__/renderer.test.ts +286 -0
- package/src/resume/renderer.ts +286 -0
- package/src/svg/AGENTS.md +22 -0
- package/src/svg/components.ts +266 -0
- package/src/svg/icons.ts +83 -0
- package/src/themes/AGENTS.md +60 -0
- package/src/themes/__tests__/themes.test.ts +187 -0
- package/src/themes/github-dark/index.ts +46 -0
- package/src/themes/github-dark/palette.ts +19 -0
- package/src/themes/github-light/index.ts +46 -0
- package/src/themes/github-light/palette.ts +19 -0
- package/src/themes/onedark/index.ts +46 -0
- package/src/themes/onedark/palette.ts +19 -0
- package/src/themes/registry.ts +18 -0
- package/src/themes/shared/charts.ts +112 -0
- package/src/themes/shared/context.ts +47 -0
- package/src/themes/shared/footer.ts +52 -0
- package/src/themes/shared/header.ts +29 -0
- package/src/themes/shared/heatmap.ts +229 -0
- package/src/themes/shared/helpers.ts +103 -0
- package/src/themes/shared/inventory.ts +105 -0
- package/src/themes/shared/legend.ts +91 -0
- package/src/themes/shared/sections.ts +20 -0
- package/src/themes/shared/stats.ts +60 -0
- package/src/types.ts +106 -0
- package/tsconfig.json +18 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SectionResult, ShowcaseData } from "../../types.js";
|
|
2
|
+
import { svgText } from "../../svg/components.js";
|
|
3
|
+
import type { RenderContext } from "./context.js";
|
|
4
|
+
import { formatDateRange, separator } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
export function renderHeader(ctx: RenderContext, data: ShowcaseData, y: number): SectionResult {
|
|
7
|
+
const startY = y;
|
|
8
|
+
const parts: string[] = [];
|
|
9
|
+
|
|
10
|
+
parts.push(
|
|
11
|
+
svgText(ctx.padX, startY + 30, "Wingman Stats", {
|
|
12
|
+
fill: ctx.colors.blue,
|
|
13
|
+
size: 16,
|
|
14
|
+
weight: "bold",
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
parts.push(
|
|
18
|
+
svgText(ctx.cardWidth - ctx.padX, startY + 30, formatDateRange(data.period.since, data.period.until), {
|
|
19
|
+
fill: ctx.colors.muted,
|
|
20
|
+
size: 11,
|
|
21
|
+
anchor: "end",
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const endY = startY + 48;
|
|
26
|
+
parts.push(separator(ctx, endY));
|
|
27
|
+
|
|
28
|
+
return { svg: parts.join("\n"), height: endY - startY };
|
|
29
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { SectionResult, ShowcaseData } from "../../types.js";
|
|
2
|
+
import { svgRect, svgText } from "../../svg/components.js";
|
|
3
|
+
import { ICONS, svgIcon } from "../../svg/icons.js";
|
|
4
|
+
import type { RenderContext } from "./context.js";
|
|
5
|
+
import { separator, topModels } from "./helpers.js";
|
|
6
|
+
|
|
7
|
+
export function renderActivityHeatmap(ctx: RenderContext, data: ShowcaseData, y: number): SectionResult {
|
|
8
|
+
const startY = y;
|
|
9
|
+
const parts: string[] = [];
|
|
10
|
+
const agents = data.agents.slice(0, 6);
|
|
11
|
+
const models = topModels(data, 5);
|
|
12
|
+
|
|
13
|
+
// Generate ALL days in the period (since → until), with min 60 days
|
|
14
|
+
const MIN_HEATMAP_DAYS = 60;
|
|
15
|
+
const periodStart = new Date(data.period.since);
|
|
16
|
+
periodStart.setHours(0, 0, 0, 0);
|
|
17
|
+
const periodEnd = new Date(data.period.until);
|
|
18
|
+
periodEnd.setHours(23, 59, 59, 999);
|
|
19
|
+
const periodDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / 86400000) + 1;
|
|
20
|
+
|
|
21
|
+
// If period < 60 days, extend start backwards
|
|
22
|
+
const heatmapStart = new Date(periodStart);
|
|
23
|
+
if (periodDays < MIN_HEATMAP_DAYS) {
|
|
24
|
+
heatmapStart.setDate(heatmapStart.getDate() - (MIN_HEATMAP_DAYS - periodDays));
|
|
25
|
+
}
|
|
26
|
+
heatmapStart.setHours(0, 0, 0, 0);
|
|
27
|
+
|
|
28
|
+
const allDays: string[] = [];
|
|
29
|
+
const cursor = new Date(heatmapStart);
|
|
30
|
+
while (cursor <= periodEnd) {
|
|
31
|
+
const y2 = cursor.getFullYear();
|
|
32
|
+
const m2 = String(cursor.getMonth() + 1).padStart(2, "0");
|
|
33
|
+
const d2 = String(cursor.getDate()).padStart(2, "0");
|
|
34
|
+
allDays.push(`${y2}-${m2}-${d2}`);
|
|
35
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (allDays.length === 0) return { svg: "", height: 0 };
|
|
39
|
+
|
|
40
|
+
parts.push(svgIcon(ctx.padX, startY + 5, ICONS.calendar3, { fill: ctx.colors.secondary, size: 11 }));
|
|
41
|
+
parts.push(
|
|
42
|
+
svgText(ctx.padX + 14, startY + 16, "ACTIVITY", { fill: ctx.colors.secondary, size: 11 }),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// ── Map every day to (col=week, row=dow) ──
|
|
46
|
+
const firstDate = new Date(allDays[0] + "T00:00:00");
|
|
47
|
+
const firstDow = (firstDate.getDay() + 6) % 7; // Mon=0..Sun=6
|
|
48
|
+
|
|
49
|
+
const dayGrid = new Map<string, { col: number; row: number }>();
|
|
50
|
+
let maxCol = 0;
|
|
51
|
+
for (const day of allDays) {
|
|
52
|
+
const date = new Date(day + "T00:00:00");
|
|
53
|
+
const dow = (date.getDay() + 6) % 7;
|
|
54
|
+
const daysSinceStart = Math.round((date.getTime() - firstDate.getTime()) / 86400000) + firstDow;
|
|
55
|
+
const col = Math.floor(daysSinceStart / 7);
|
|
56
|
+
dayGrid.set(day, { col, row: dow });
|
|
57
|
+
if (col > maxCol) maxCol = col;
|
|
58
|
+
}
|
|
59
|
+
const numWeeks = maxCol + 1;
|
|
60
|
+
|
|
61
|
+
// ── Flowing 3-column layout: [heatmap] [labels] [ratio] [bar chart] ──
|
|
62
|
+
const gridX = ctx.padX;
|
|
63
|
+
const cellGap = 2;
|
|
64
|
+
const cellW = Math.min(16, Math.max(6, 12));
|
|
65
|
+
const colStep = cellW + cellGap;
|
|
66
|
+
const heatmapW = numWeeks * colStep - cellGap;
|
|
67
|
+
|
|
68
|
+
const gapLR = 8;
|
|
69
|
+
// --days 180 produces 181 allDays (inclusive), so use > 182 to cover that
|
|
70
|
+
const isCompact = periodDays > 182; // N > ~180 days → ratio-only
|
|
71
|
+
const labelColW = isCompact ? 28 : 28; // "Mon" is ~24px, keep tight
|
|
72
|
+
const ratioColW = 38; // "18.4%" right-aligned
|
|
73
|
+
const labelX = gridX + heatmapW + gapLR;
|
|
74
|
+
const labelCenterX = labelX + labelColW / 2;
|
|
75
|
+
const ratioEndX = labelX + labelColW + ratioColW;
|
|
76
|
+
|
|
77
|
+
// Bar chart: flowing after ratio, dynamic width
|
|
78
|
+
const barStartX = ratioEndX + 4;
|
|
79
|
+
const availableBarW = ctx.cardWidth - ctx.padX - barStartX;
|
|
80
|
+
// 1x base = 80px, max 2x = 160px, capped by available space
|
|
81
|
+
const baseBarW = 80;
|
|
82
|
+
const barChartW = isCompact ? 0 : Math.max(baseBarW, Math.min(baseBarW * 2, availableBarW));
|
|
83
|
+
|
|
84
|
+
const heatLineH = 10;
|
|
85
|
+
const heatLineGap = 2;
|
|
86
|
+
const mainCellH = cellW; // square cells
|
|
87
|
+
const mainCellGap = 2;
|
|
88
|
+
const rowStep = mainCellH + mainCellGap;
|
|
89
|
+
|
|
90
|
+
// ── Aggregate weekly totals per agent & model ──
|
|
91
|
+
const agentWeekly = agents.map((agent) => {
|
|
92
|
+
const weekly = Array<number>(numWeeks).fill(0);
|
|
93
|
+
for (const [day, tokens] of Object.entries(agent.dailyActivity)) {
|
|
94
|
+
const pos = dayGrid.get(day);
|
|
95
|
+
if (pos) weekly[pos.col] += tokens;
|
|
96
|
+
}
|
|
97
|
+
return weekly;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const modelWeekly = models.map((model) => {
|
|
101
|
+
const weekly = Array<number>(numWeeks).fill(0);
|
|
102
|
+
const activity = data.modelDailyActivity[model.id] ?? {};
|
|
103
|
+
for (const day of allDays) {
|
|
104
|
+
const pos = dayGrid.get(day);
|
|
105
|
+
if (pos) weekly[pos.col] += activity[day] ?? 0;
|
|
106
|
+
}
|
|
107
|
+
return weekly;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Compute DOW totals for bar chart / ratio ──
|
|
111
|
+
const dowTotals = Array<number>(7).fill(0);
|
|
112
|
+
for (const [day] of dayGrid) {
|
|
113
|
+
const date = new Date(day + "T00:00:00");
|
|
114
|
+
const dow = (date.getDay() + 6) % 7;
|
|
115
|
+
let total = 0;
|
|
116
|
+
for (const agent of agents) {
|
|
117
|
+
total += agent.dailyActivity[day] ?? 0;
|
|
118
|
+
}
|
|
119
|
+
dowTotals[dow] += total;
|
|
120
|
+
}
|
|
121
|
+
const maxDowTotal = Math.max(...dowTotals, 1);
|
|
122
|
+
const totalAllDow = dowTotals.reduce((s, v) => s + v, 0) || 1;
|
|
123
|
+
|
|
124
|
+
let curY = startY + 28;
|
|
125
|
+
|
|
126
|
+
// ── Heat-line row 1: top agent per week (solid fill + people icon) ──
|
|
127
|
+
parts.push(svgIcon(labelX, curY + 1, ICONS.people, { fill: ctx.colors.muted, size: 9 }));
|
|
128
|
+
parts.push(
|
|
129
|
+
svgText(labelX + 12, curY + 9, "Agent", { fill: ctx.colors.muted, size: 9 }),
|
|
130
|
+
);
|
|
131
|
+
for (let w = 0; w < numWeeks; w++) {
|
|
132
|
+
let bestIdx = 0;
|
|
133
|
+
let bestVal = 0;
|
|
134
|
+
agentWeekly.forEach((weekly, i) => {
|
|
135
|
+
if (weekly[w] > bestVal) { bestVal = weekly[w]; bestIdx = i; }
|
|
136
|
+
});
|
|
137
|
+
const color = ctx.agentColors[bestIdx % ctx.agentColors.length];
|
|
138
|
+
const opacity = bestVal === 0 ? 0.08 : 0.85;
|
|
139
|
+
parts.push(svgRect(gridX + w * colStep, curY, cellW, heatLineH, { fill: color, rx: 2, opacity }));
|
|
140
|
+
}
|
|
141
|
+
curY += heatLineH + heatLineGap;
|
|
142
|
+
|
|
143
|
+
// ── Heat-line row 2: top model per week (solid fill + stars icon) ──
|
|
144
|
+
parts.push(svgIcon(labelX, curY + 1, ICONS.stars, { fill: ctx.colors.muted, size: 9 }));
|
|
145
|
+
parts.push(
|
|
146
|
+
svgText(labelX + 12, curY + 9, "Model", { fill: ctx.colors.muted, size: 9 }),
|
|
147
|
+
);
|
|
148
|
+
for (let w = 0; w < numWeeks; w++) {
|
|
149
|
+
let bestIdx = 0;
|
|
150
|
+
let bestVal = 0;
|
|
151
|
+
modelWeekly.forEach((weekly, i) => {
|
|
152
|
+
if (weekly[w] > bestVal) { bestVal = weekly[w]; bestIdx = i; }
|
|
153
|
+
});
|
|
154
|
+
const color = ctx.modelColors[bestIdx % ctx.modelColors.length];
|
|
155
|
+
const opacity = bestVal === 0 ? 0.08 : 0.85;
|
|
156
|
+
parts.push(svgRect(gridX + w * colStep, curY, cellW, heatLineH, { fill: color, rx: 2, opacity }));
|
|
157
|
+
}
|
|
158
|
+
curY += heatLineH + 6;
|
|
159
|
+
|
|
160
|
+
// ── Main heatmap + labels + DOW bars/ratios (aligned rows) ──
|
|
161
|
+
const dowLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
162
|
+
const heatmapStartY = curY;
|
|
163
|
+
|
|
164
|
+
// Compute daily totals
|
|
165
|
+
const dayTotals = new Map<string, number>();
|
|
166
|
+
for (const day of allDays) {
|
|
167
|
+
let total = 0;
|
|
168
|
+
for (const agent of agents) {
|
|
169
|
+
total += agent.dailyActivity[day] ?? 0;
|
|
170
|
+
}
|
|
171
|
+
dayTotals.set(day, total);
|
|
172
|
+
}
|
|
173
|
+
const maxDayTotal = Math.max(...dayTotals.values(), 1);
|
|
174
|
+
|
|
175
|
+
for (let r = 0; r < 7; r++) {
|
|
176
|
+
const ry = heatmapStartY + r * rowStep;
|
|
177
|
+
const ratio = (dowTotals[r] / totalAllDow * 100).toFixed(1);
|
|
178
|
+
|
|
179
|
+
// Label (center column)
|
|
180
|
+
parts.push(
|
|
181
|
+
svgText(labelCenterX, ry + mainCellH / 2 + 3, dowLabels[r], { fill: ctx.colors.muted, size: 9, anchor: "middle" }),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Ratio text (left of bar, right-aligned)
|
|
185
|
+
parts.push(
|
|
186
|
+
svgText(ratioEndX, ry + mainCellH / 2 + 3, `${ratio}%`, { fill: ctx.colors.muted, size: 9, anchor: "end" }),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (!isCompact) {
|
|
190
|
+
// Bar (N <= 180)
|
|
191
|
+
const barW = (dowTotals[r] / maxDowTotal) * barChartW;
|
|
192
|
+
if (barW > 0) {
|
|
193
|
+
parts.push(svgRect(barStartX, ry + 2, barW, mainCellH - 4, { fill: ctx.colors.green, rx: 3, opacity: 0.7 }));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Green heatmap cells
|
|
199
|
+
for (const [day, { col, row }] of dayGrid) {
|
|
200
|
+
const cx = gridX + col * colStep;
|
|
201
|
+
const cy = heatmapStartY + row * rowStep;
|
|
202
|
+
const totalTokens = dayTotals.get(day) ?? 0;
|
|
203
|
+
|
|
204
|
+
if (totalTokens === 0) {
|
|
205
|
+
parts.push(svgRect(cx, cy, cellW, mainCellH, { fill: ctx.colors.separator, rx: 3, opacity: 0.2 }));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const strength = 0.15 + (totalTokens / maxDayTotal) * 0.85;
|
|
210
|
+
parts.push(svgRect(cx, cy, cellW, mainCellH, { fill: ctx.colors.green, rx: 3, opacity: strength }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── "less → more" scale ──
|
|
214
|
+
const gridH = 7 * rowStep - mainCellGap;
|
|
215
|
+
curY = heatmapStartY + gridH + 10;
|
|
216
|
+
parts.push(svgText(gridX, curY, "less", { fill: ctx.colors.muted, size: 9 }));
|
|
217
|
+
const scaleX = gridX + 28;
|
|
218
|
+
const scaleOpacities = [0.15, 0.35, 0.55, 0.75, 1.0];
|
|
219
|
+
for (let si = 0; si < scaleOpacities.length; si++) {
|
|
220
|
+
parts.push(svgRect(scaleX + si * 13, curY - 8, 10, 10, {
|
|
221
|
+
fill: ctx.colors.green, rx: 2, opacity: scaleOpacities[si],
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
parts.push(svgText(scaleX + scaleOpacities.length * 13 + 4, curY, "more", { fill: ctx.colors.muted, size: 9 }));
|
|
225
|
+
|
|
226
|
+
curY += 10;
|
|
227
|
+
parts.push(separator(ctx, curY));
|
|
228
|
+
return { svg: parts.join("\n"), height: curY - startY };
|
|
229
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ShowcaseData } from "../../types.js";
|
|
2
|
+
import { formatDate, svgLine } from "../../svg/components.js";
|
|
3
|
+
import type { RenderContext } from "./context.js";
|
|
4
|
+
|
|
5
|
+
const MIN_CARD_WIDTH = 660;
|
|
6
|
+
const MAX_CARD_WIDTH = 1200;
|
|
7
|
+
|
|
8
|
+
export function separator(ctx: RenderContext, y: number): string {
|
|
9
|
+
return svgLine(ctx.padX, y, ctx.cardWidth - ctx.padX, y, { stroke: ctx.colors.separator });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function shortMonth(d: Date): string {
|
|
13
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
14
|
+
return months[d.getMonth()];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatDateRange(since: Date, until: Date): string {
|
|
18
|
+
const sameYear = since.getFullYear() === until.getFullYear();
|
|
19
|
+
const sameMonth = sameYear && since.getMonth() === until.getMonth();
|
|
20
|
+
|
|
21
|
+
if (sameMonth) {
|
|
22
|
+
return `${shortMonth(since)} ${since.getDate()} \u2013 ${until.getDate()}, ${until.getFullYear()}`;
|
|
23
|
+
}
|
|
24
|
+
if (sameYear) {
|
|
25
|
+
return `${shortMonth(since)} ${since.getDate()} \u2013 ${shortMonth(until)} ${until.getDate()}, ${until.getFullYear()}`;
|
|
26
|
+
}
|
|
27
|
+
return `${formatDate(since)} \u2013 ${formatDate(until)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function collectSortedDays(data: ShowcaseData): string[] {
|
|
31
|
+
const days = new Set<string>();
|
|
32
|
+
for (const agent of data.agents) {
|
|
33
|
+
for (const day of Object.keys(agent.dailyActivity)) {
|
|
34
|
+
days.add(day);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...days].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function topModels(data: ShowcaseData, limit: number): { id: string; tokens: number; cost: number }[] {
|
|
41
|
+
const map = new Map<string, { tokens: number; cost: number }>();
|
|
42
|
+
for (const agent of data.agents) {
|
|
43
|
+
for (const [modelId, stats] of Object.entries(agent.models)) {
|
|
44
|
+
const existing = map.get(modelId);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.tokens += stats.tokens;
|
|
47
|
+
existing.cost += stats.cost;
|
|
48
|
+
} else {
|
|
49
|
+
map.set(modelId, { tokens: stats.tokens, cost: stats.cost });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [...map.entries()]
|
|
54
|
+
.map(([id, s]) => ({ id, ...s }))
|
|
55
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
56
|
+
.slice(0, limit);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function computePeriodSize(data: ShowcaseData): { numWeeks: number; numDays: number } {
|
|
60
|
+
const periodStart = new Date(data.period.since);
|
|
61
|
+
periodStart.setHours(0, 0, 0, 0);
|
|
62
|
+
const end = new Date(data.period.until);
|
|
63
|
+
end.setHours(23, 59, 59, 999);
|
|
64
|
+
const numDays = Math.round((end.getTime() - periodStart.getTime()) / 86400000) + 1;
|
|
65
|
+
|
|
66
|
+
const MIN_HEATMAP_DAYS = 60;
|
|
67
|
+
const heatmapStart = new Date(periodStart);
|
|
68
|
+
if (numDays < MIN_HEATMAP_DAYS) {
|
|
69
|
+
heatmapStart.setDate(heatmapStart.getDate() - (MIN_HEATMAP_DAYS - numDays));
|
|
70
|
+
}
|
|
71
|
+
heatmapStart.setHours(0, 0, 0, 0);
|
|
72
|
+
|
|
73
|
+
const firstDow = (heatmapStart.getDay() + 6) % 7;
|
|
74
|
+
const cursor = new Date(heatmapStart);
|
|
75
|
+
const startTime = cursor.getTime();
|
|
76
|
+
let maxCol = 0;
|
|
77
|
+
while (cursor <= end) {
|
|
78
|
+
const daysSinceStart = Math.round((cursor.getTime() - startTime) / 86400000) + firstDow;
|
|
79
|
+
maxCol = Math.floor(daysSinceStart / 7);
|
|
80
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
81
|
+
}
|
|
82
|
+
return { numWeeks: maxCol + 1, numDays };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function computeCardWidth(numWeeks: number, numDays: number): number {
|
|
86
|
+
const cellW = Math.min(16, Math.max(6, 12));
|
|
87
|
+
const colStep = cellW + 2;
|
|
88
|
+
const effectiveWeeks = Math.max(numWeeks, Math.ceil(60 / 7) + 1);
|
|
89
|
+
const heatmapW = effectiveWeeks * colStep - 2;
|
|
90
|
+
const labelColW = 28;
|
|
91
|
+
const ratioColW = 38;
|
|
92
|
+
const gapLR = 8;
|
|
93
|
+
const padX = 24;
|
|
94
|
+
|
|
95
|
+
if (numDays <= 182) {
|
|
96
|
+
const minBarW = 80;
|
|
97
|
+
const barGap = 4;
|
|
98
|
+
const needed = padX + heatmapW + gapLR + labelColW + ratioColW + barGap + minBarW + padX;
|
|
99
|
+
return Math.max(MIN_CARD_WIDTH, Math.min(MAX_CARD_WIDTH, needed));
|
|
100
|
+
}
|
|
101
|
+
const needed = padX + heatmapW + gapLR + labelColW + ratioColW + padX;
|
|
102
|
+
return Math.max(MIN_CARD_WIDTH, Math.min(MAX_CARD_WIDTH, needed));
|
|
103
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { SectionResult, ShowcaseData } from "../../types.js";
|
|
2
|
+
import { svgPill, svgText } from "../../svg/components.js";
|
|
3
|
+
import { ICONS, svgIcon } from "../../svg/icons.js";
|
|
4
|
+
import type { RenderContext } from "./context.js";
|
|
5
|
+
|
|
6
|
+
export function renderInventory(ctx: RenderContext, data: ShowcaseData, y: number): SectionResult {
|
|
7
|
+
const inv = data.inventory;
|
|
8
|
+
const hasPlugins = inv.plugins.length > 0;
|
|
9
|
+
const hasMcp = inv.mcpServers.length > 0;
|
|
10
|
+
const hasSkills = inv.skills.length > 0;
|
|
11
|
+
|
|
12
|
+
if (!hasPlugins && !hasMcp && !hasSkills) {
|
|
13
|
+
return { svg: "", height: 0 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Build agent → color map (same order as AGENTS section)
|
|
17
|
+
const agentColorMap = new Map<string, string>();
|
|
18
|
+
for (let i = 0; i < data.agents.length; i++) {
|
|
19
|
+
agentColorMap.set(data.agents[i].agent, ctx.agentColors[i % ctx.agentColors.length]);
|
|
20
|
+
}
|
|
21
|
+
const toBadges = (sources: string[]) => sources.map(s => agentColorMap.get(s) ?? ctx.colors.muted);
|
|
22
|
+
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
const startY = y;
|
|
25
|
+
parts.push(svgIcon(ctx.padX, startY + 5, ICONS.box, { fill: ctx.colors.secondary, size: 11 }));
|
|
26
|
+
parts.push(
|
|
27
|
+
svgText(ctx.padX + 14, startY + 16, "INVENTORY", { fill: ctx.colors.secondary, size: 11 }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const pillGap = 6;
|
|
31
|
+
const maxX = ctx.cardWidth - ctx.padX;
|
|
32
|
+
const indent = 20;
|
|
33
|
+
const rowH = 26;
|
|
34
|
+
let curY = startY + 28;
|
|
35
|
+
|
|
36
|
+
// ── Plugins (hierarchical: plugin → skills, agents, commands) ──
|
|
37
|
+
for (const plugin of inv.plugins) {
|
|
38
|
+
const label = plugin.version ? `${plugin.name} v${plugin.version}` : plugin.name;
|
|
39
|
+
parts.push(svgIcon(ctx.padX, curY + 3, ICONS.puzzle, { fill: ctx.colors.blue, size: 12 }));
|
|
40
|
+
const pill = svgPill(ctx.padX + 16, curY, label, {
|
|
41
|
+
fill: "#1f6feb22",
|
|
42
|
+
textFill: ctx.colors.blue,
|
|
43
|
+
badges: toBadges(plugin.sources),
|
|
44
|
+
});
|
|
45
|
+
parts.push(pill.svg);
|
|
46
|
+
curY += rowH;
|
|
47
|
+
|
|
48
|
+
// Helper: render a labeled row of pills with wrapping
|
|
49
|
+
const renderPillRow = (label: string, labelW: number, items: string[], prefix = "") => {
|
|
50
|
+
parts.push(
|
|
51
|
+
svgText(ctx.padX + indent, curY + 12, label, { fill: ctx.colors.muted, size: 9 }),
|
|
52
|
+
);
|
|
53
|
+
let px = ctx.padX + indent + labelW;
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const text = prefix + item;
|
|
56
|
+
const sp = svgPill(px, curY, text, { fill: ctx.colors.separator, textFill: ctx.colors.secondary });
|
|
57
|
+
if (px + sp.width > maxX && px > ctx.padX + indent + labelW) {
|
|
58
|
+
curY += rowH;
|
|
59
|
+
px = ctx.padX + indent + labelW;
|
|
60
|
+
const sp2 = svgPill(px, curY, text, { fill: ctx.colors.separator, textFill: ctx.colors.secondary });
|
|
61
|
+
parts.push(sp2.svg);
|
|
62
|
+
px += sp2.width + pillGap;
|
|
63
|
+
} else {
|
|
64
|
+
parts.push(sp.svg);
|
|
65
|
+
px += sp.width + pillGap;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
curY += rowH;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (plugin.skills.length > 0) renderPillRow("skills", 36, plugin.skills);
|
|
72
|
+
if (plugin.agents.length > 0) renderPillRow("agents", 40, plugin.agents);
|
|
73
|
+
if (plugin.commands.length > 0) renderPillRow("cmds", 32, plugin.commands, "/");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Helper: render a section of InventoryItems with badges inside pills
|
|
77
|
+
const renderItemSection = (
|
|
78
|
+
iconPath: string, iconColor: string, title: string, items: typeof inv.mcpServers,
|
|
79
|
+
) => {
|
|
80
|
+
parts.push(svgIcon(ctx.padX, curY + 3, iconPath, { fill: iconColor, size: 12 }));
|
|
81
|
+
parts.push(svgText(ctx.padX + 16, curY + 12, title, { fill: ctx.colors.secondary, size: 10 }));
|
|
82
|
+
curY += 18;
|
|
83
|
+
let px = ctx.padX + indent;
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
const badges = toBadges(item.sources);
|
|
86
|
+
const sp = svgPill(px, curY, item.name, { fill: ctx.colors.separator, textFill: ctx.colors.secondary, badges });
|
|
87
|
+
if (px + sp.width > maxX && px > ctx.padX + indent) {
|
|
88
|
+
curY += rowH;
|
|
89
|
+
px = ctx.padX + indent;
|
|
90
|
+
const sp2 = svgPill(px, curY, item.name, { fill: ctx.colors.separator, textFill: ctx.colors.secondary, badges });
|
|
91
|
+
parts.push(sp2.svg);
|
|
92
|
+
px += sp2.width + pillGap;
|
|
93
|
+
} else {
|
|
94
|
+
parts.push(sp.svg);
|
|
95
|
+
px += sp.width + pillGap;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
curY += rowH;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (hasMcp) renderItemSection(ICONS.tools, ctx.colors.green, "MCP Servers", inv.mcpServers);
|
|
102
|
+
if (hasSkills) renderItemSection(ICONS.hexagon, ctx.colors.purple, "Skills", inv.skills);
|
|
103
|
+
|
|
104
|
+
return { svg: parts.join("\n"), height: curY - startY };
|
|
105
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SectionResult, ShowcaseData } from "../../types.js";
|
|
2
|
+
import { escapeXml, svgCircle, svgRect, svgText } from "../../svg/components.js";
|
|
3
|
+
import type { RenderContext } from "./context.js";
|
|
4
|
+
import { separator, topModels } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
export function renderLegend(ctx: RenderContext, data: ShowcaseData, y: number): SectionResult {
|
|
7
|
+
const startY = y + 8;
|
|
8
|
+
const parts: string[] = [];
|
|
9
|
+
const agents = data.agents.slice(0, 6);
|
|
10
|
+
const models = topModels(data, 5);
|
|
11
|
+
|
|
12
|
+
const agentRowH = 22;
|
|
13
|
+
const modelRowH = 22;
|
|
14
|
+
const agentCount = agents.length;
|
|
15
|
+
const modelCount = models.length;
|
|
16
|
+
|
|
17
|
+
const squareX = 200;
|
|
18
|
+
const circleX = ctx.cardWidth - 200;
|
|
19
|
+
const agentTextX = squareX - 10;
|
|
20
|
+
const modelTextX = circleX + 14;
|
|
21
|
+
|
|
22
|
+
const totalH = Math.max(agentCount, modelCount) * agentRowH;
|
|
23
|
+
const agentStartY = startY + (totalH - agentCount * agentRowH) / 2;
|
|
24
|
+
const modelStartY = startY + (totalH - modelCount * modelRowH) / 2;
|
|
25
|
+
|
|
26
|
+
const modelYMap = new Map<string, number>();
|
|
27
|
+
models.forEach((m, i) => {
|
|
28
|
+
modelYMap.set(m.id, modelStartY + i * modelRowH + modelRowH / 2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let maxPairTokens = 1;
|
|
32
|
+
for (const agent of agents) {
|
|
33
|
+
for (const [, stats] of Object.entries(agent.models)) {
|
|
34
|
+
if (stats.tokens > maxPairTokens) maxPairTokens = stats.tokens;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Flow lines (behind labels)
|
|
39
|
+
for (let ai = 0; ai < agents.length; ai++) {
|
|
40
|
+
const agent = agents[ai];
|
|
41
|
+
const agentColor = ctx.agentColors[ai % ctx.agentColors.length];
|
|
42
|
+
const ay = agentStartY + ai * agentRowH + agentRowH / 2;
|
|
43
|
+
const sx = squareX + 12;
|
|
44
|
+
|
|
45
|
+
for (const [modelId, stats] of Object.entries(agent.models)) {
|
|
46
|
+
const my = modelYMap.get(modelId);
|
|
47
|
+
if (my === undefined) continue;
|
|
48
|
+
|
|
49
|
+
const lineW = Math.max(1, (stats.tokens / maxPairTokens) * 8);
|
|
50
|
+
const cx = circleX - 6;
|
|
51
|
+
const midX = (sx + cx) / 2;
|
|
52
|
+
parts.push(
|
|
53
|
+
`<path d="M ${sx} ${ay} C ${midX} ${ay}, ${midX} ${my}, ${cx} ${my}" ` +
|
|
54
|
+
`fill="none" stroke="${agentColor}" stroke-width="${lineW.toFixed(1)}" opacity="0.35"/>`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Agent labels + squares
|
|
60
|
+
agents.forEach((agent, i) => {
|
|
61
|
+
const cy = agentStartY + i * agentRowH + agentRowH / 2;
|
|
62
|
+
const color = ctx.agentColors[i % ctx.agentColors.length];
|
|
63
|
+
parts.push(svgRect(squareX, cy - 5, 10, 10, { fill: color, rx: 2 }));
|
|
64
|
+
parts.push(
|
|
65
|
+
svgText(agentTextX, cy + 4, escapeXml(agent.displayName), {
|
|
66
|
+
fill: ctx.colors.primary,
|
|
67
|
+
size: 11,
|
|
68
|
+
anchor: "end",
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Model labels + circles
|
|
74
|
+
models.forEach((model, i) => {
|
|
75
|
+
const cy = modelStartY + i * modelRowH + modelRowH / 2;
|
|
76
|
+
const color = ctx.modelColors[i % ctx.modelColors.length];
|
|
77
|
+
const truncId = model.id.length > 24 ? model.id.slice(0, 24) + "\u2026" : model.id;
|
|
78
|
+
parts.push(svgCircle(circleX, cy, 5, { fill: color }));
|
|
79
|
+
parts.push(
|
|
80
|
+
svgText(modelTextX, cy + 4, truncId, {
|
|
81
|
+
fill: ctx.colors.primary,
|
|
82
|
+
size: 11,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const endY = startY + totalH + 8;
|
|
88
|
+
parts.push(separator(ctx, endY));
|
|
89
|
+
|
|
90
|
+
return { svg: parts.join("\n"), height: endY - y };
|
|
91
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SectionEntry } from "./context.js";
|
|
2
|
+
import { renderHeader } from "./header.js";
|
|
3
|
+
import { renderTopStats } from "./stats.js";
|
|
4
|
+
import { renderLegend } from "./legend.js";
|
|
5
|
+
import { renderCharts } from "./charts.js";
|
|
6
|
+
import { renderActivityHeatmap } from "./heatmap.js";
|
|
7
|
+
import { renderInventory } from "./inventory.js";
|
|
8
|
+
import { renderFooter } from "./footer.js";
|
|
9
|
+
|
|
10
|
+
export const ALL_SECTIONS: SectionEntry[] = [
|
|
11
|
+
{ name: "header", render: renderHeader },
|
|
12
|
+
{ name: "stats", render: renderTopStats },
|
|
13
|
+
{ name: "legend", render: renderLegend },
|
|
14
|
+
{ name: "charts", render: renderCharts },
|
|
15
|
+
{ name: "heatmap", render: renderActivityHeatmap },
|
|
16
|
+
{ name: "inventory", render: renderInventory },
|
|
17
|
+
{ name: "footer", render: renderFooter },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const SECTION_NAMES = ALL_SECTIONS.map((s) => s.name);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { SectionResult, ShowcaseData } from "../../types.js";
|
|
2
|
+
import { formatCost, formatNumber, svgText } from "../../svg/components.js";
|
|
3
|
+
import { ICONS, svgIcon } from "../../svg/icons.js";
|
|
4
|
+
import type { RenderContext } from "./context.js";
|
|
5
|
+
import { separator } from "./helpers.js";
|
|
6
|
+
|
|
7
|
+
export function renderTopStats(ctx: RenderContext, data: ShowcaseData, y: number): SectionResult {
|
|
8
|
+
const startY = y;
|
|
9
|
+
const parts: string[] = [];
|
|
10
|
+
const colWidth = ctx.contentWidth / 3;
|
|
11
|
+
|
|
12
|
+
const col1x = ctx.padX;
|
|
13
|
+
parts.push(svgIcon(col1x, startY + 11, ICONS.hash, { fill: ctx.colors.secondary, size: 11 }));
|
|
14
|
+
parts.push(
|
|
15
|
+
svgText(col1x + 14, startY + 22, "TOTAL TOKENS", { fill: ctx.colors.secondary, size: 11 }),
|
|
16
|
+
);
|
|
17
|
+
parts.push(
|
|
18
|
+
svgText(col1x, startY + 52, formatNumber(data.totals.tokens), {
|
|
19
|
+
fill: ctx.colors.primary,
|
|
20
|
+
size: 28,
|
|
21
|
+
weight: "bold",
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const breakdown = `${formatNumber(data.totals.inputTokens)} in / ${formatNumber(data.totals.outputTokens)} out / ${formatNumber(data.totals.cacheReadTokens)} read / ${formatNumber(data.totals.cacheWriteTokens)} write`;
|
|
26
|
+
parts.push(
|
|
27
|
+
svgText(col1x, startY + 66, breakdown, { fill: ctx.colors.secondary, size: 10 }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const col2x = ctx.padX + colWidth;
|
|
31
|
+
parts.push(svgIcon(col2x, startY + 11, ICONS.currencyDollar, { fill: ctx.colors.secondary, size: 11 }));
|
|
32
|
+
parts.push(
|
|
33
|
+
svgText(col2x + 14, startY + 22, "TOTAL COST", { fill: ctx.colors.secondary, size: 11 }),
|
|
34
|
+
);
|
|
35
|
+
parts.push(
|
|
36
|
+
svgText(col2x, startY + 52, formatCost(data.totals.cost), {
|
|
37
|
+
fill: ctx.colors.green,
|
|
38
|
+
size: 28,
|
|
39
|
+
weight: "bold",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const col3x = ctx.padX + colWidth * 2;
|
|
44
|
+
parts.push(svgIcon(col3x, startY + 11, ICONS.terminal, { fill: ctx.colors.secondary, size: 11 }));
|
|
45
|
+
parts.push(
|
|
46
|
+
svgText(col3x + 14, startY + 22, "SESSIONS", { fill: ctx.colors.secondary, size: 11 }),
|
|
47
|
+
);
|
|
48
|
+
parts.push(
|
|
49
|
+
svgText(col3x, startY + 52, formatNumber(data.totals.sessions), {
|
|
50
|
+
fill: ctx.colors.purple,
|
|
51
|
+
size: 28,
|
|
52
|
+
weight: "bold",
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const endY = startY + 72;
|
|
57
|
+
parts.push(separator(ctx, endY));
|
|
58
|
+
|
|
59
|
+
return { svg: parts.join("\n"), height: endY - startY };
|
|
60
|
+
}
|