fapony 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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,120 @@
1
+ // src/usage/cli.ts — cmdUsageWeb + Bun.serve routes
2
+ //
3
+ // Reads from usage-cache.jsonl (written by `fapony usage-scan`).
4
+ // No live session scanning — serves static HTML.
5
+
6
+ import { loadConfig } from "../db/index.js";
7
+ import type { PassiveUsageResult } from "../session/types.js";
8
+ import { type CacheEntry, cacheMeta, readCache } from "./cache.js";
9
+ import { renderUsageHtml } from "./render.js";
10
+
11
+ function cacheToResult(entry: CacheEntry | undefined): PassiveUsageResult {
12
+ if (!entry) {
13
+ return {
14
+ total_tokens_input: 0,
15
+ total_tokens_output: 0,
16
+ total_tokens_reasoning: 0,
17
+ total_tokens_cache_read: 0,
18
+ total_tokens_cache_write: 0,
19
+ total_cost: 0,
20
+ session_count: 0,
21
+ by_model: [],
22
+ };
23
+ }
24
+ return {
25
+ total_tokens_input: entry.total_tokens_input,
26
+ total_tokens_output: entry.total_tokens_output,
27
+ total_tokens_reasoning: entry.total_tokens_reasoning,
28
+ total_tokens_cache_read: entry.total_tokens_cache_read,
29
+ total_tokens_cache_write: entry.total_tokens_cache_write,
30
+ total_cost: entry.total_cost,
31
+ session_count: entry.session_count,
32
+ by_model: entry.by_model.map((m) => ({
33
+ ...m,
34
+ })),
35
+ ...(entry.error ? { error: entry.error } : {}),
36
+ };
37
+ }
38
+
39
+ export function cmdUsageWeb(rawArgs: string[]): void {
40
+ const config = loadConfig();
41
+ const uw = config.usageWeb ?? {};
42
+
43
+ if (rawArgs.includes("--full")) {
44
+ console.error(
45
+ "fapony usage-web: --full moved to `fapony usage-scan --full`",
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ const portArg = parseInt(rawArgs[0], 10);
51
+ const port = Number.isNaN(portArg) ? (uw.port ?? 8080) : portArg;
52
+ const hostname = rawArgs[1] || uw.hostname || "127.0.0.1";
53
+ const ownerName = uw.ownerName?.trim() ? uw.ownerName.trim() : undefined;
54
+
55
+ if (rawArgs[0] && Number.isNaN(parseInt(rawArgs[0], 10))) {
56
+ console.error("usage: fapony usage-web [port] [hostname]");
57
+ process.exit(1);
58
+ }
59
+
60
+ const entries = readCache(config);
61
+ const meta = cacheMeta(entries);
62
+
63
+ if (!meta) {
64
+ console.error(
65
+ "fapony usage-web: no usage cache found. Run `fapony usage-scan` first.",
66
+ );
67
+ process.exit(1);
68
+ }
69
+
70
+ // Group entries by worktree. Entries with worktree=null/undefined are the
71
+ // global aggregate (backwards-compatible with pre-project-dimension caches).
72
+ const byWorktree = new Map<string, CacheEntry[]>();
73
+ for (const e of entries) {
74
+ const wt = e.worktree ?? null;
75
+ const key = wt ?? "__global__";
76
+ let arr = byWorktree.get(key);
77
+ if (!arr) {
78
+ arr = [];
79
+ byWorktree.set(key, arr);
80
+ }
81
+ arr.push(e);
82
+ }
83
+
84
+ // Build per-worktree data objects + a global summary.
85
+ const projectData = new Map<
86
+ string,
87
+ {
88
+ opencode: PassiveUsageResult;
89
+ zcode: PassiveUsageResult;
90
+ claude_code: PassiveUsageResult;
91
+ codex: PassiveUsageResult;
92
+ }
93
+ >();
94
+
95
+ for (const [key, clientEntries] of byWorktree) {
96
+ const byClient = new Map(clientEntries.map((e) => [e.client, e]));
97
+ const data = {
98
+ opencode: cacheToResult(byClient.get("opencode")),
99
+ zcode: cacheToResult(byClient.get("zcode")),
100
+ claude_code: cacheToResult(byClient.get("claude_code")),
101
+ codex: cacheToResult(byClient.get("codex")),
102
+ };
103
+ projectData.set(key, data);
104
+ }
105
+
106
+ const server = Bun.serve({
107
+ hostname,
108
+ port,
109
+ fetch(_req) {
110
+ const html = renderUsageHtml(projectData, meta.scanned_at, ownerName);
111
+ return new Response(html, {
112
+ headers: { "Content-Type": "text/html; charset=utf-8" },
113
+ });
114
+ },
115
+ });
116
+
117
+ console.log(
118
+ `fapony usage-web → http://${server.hostname}:${server.port} (cache: ${meta.scanned_at}, ${meta.total_sessions} sessions, ${byWorktree.size - (byWorktree.has("__global__") ? 1 : 0)} projects)`,
119
+ );
120
+ }
@@ -0,0 +1,29 @@
1
+ // src/usage/format.ts — pure formatting helpers for usage-web
2
+
3
+ export { esc } from "../web/html.js";
4
+
5
+ export function fmtTokens(n: number): string {
6
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
7
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
8
+ return String(Math.round(n));
9
+ }
10
+
11
+ export function fmtCost(n: number | null): string {
12
+ return n !== null && n > 0 ? `~$${n.toFixed(4)}` : "—";
13
+ }
14
+
15
+ export function fmtDelta(prev: number, curr: number): string {
16
+ const d = curr - prev;
17
+ if (d === 0) return "";
18
+ return d > 0 ? `+${d.toLocaleString()}` : d.toLocaleString();
19
+ }
20
+
21
+ export function shortModel(raw: string): string {
22
+ try {
23
+ const obj = JSON.parse(raw);
24
+ if (obj && typeof obj === "object" && typeof obj.id === "string") {
25
+ return obj.id;
26
+ }
27
+ } catch {}
28
+ return raw;
29
+ }
@@ -0,0 +1,4 @@
1
+ // src/usage/index.ts — barrel re-export
2
+
3
+ export { cmdUsageWeb } from "./cli.js";
4
+ export { cmdUsageScan } from "./scan.js";
@@ -0,0 +1,523 @@
1
+ // src/usage/render.ts — static HTML generator for usage-web
2
+ //
3
+ // Pure server-rendered HTML — no client-side JS, no polling.
4
+
5
+ import { imputeResult, loadPrices, type PriceTable } from "../price/index.js";
6
+ import type { ModelBreakdown, PassiveUsageResult } from "../session/types.js";
7
+ import { esc, fmtCost, fmtTokens, shortModel } from "./format.js";
8
+
9
+ const FIELDS: (keyof ModelBreakdown)[] = [
10
+ "tokens_input",
11
+ "tokens_output",
12
+ "tokens_cache_read",
13
+ "tokens_cache_write",
14
+ "tokens_reasoning",
15
+ "session_count",
16
+ "cost",
17
+ ];
18
+
19
+ const HEADERS = [
20
+ "Provider",
21
+ "In",
22
+ "Out",
23
+ "Cache R",
24
+ "Cache W",
25
+ "Reason",
26
+ "Sess",
27
+ "Cost",
28
+ ];
29
+
30
+ interface SummaryMetrics {
31
+ sessions: number;
32
+ cacheHitRate: number;
33
+ reasoningPct: number;
34
+ outputRatio: number;
35
+ avgPerSession: number;
36
+ totalCost: number;
37
+ input: number;
38
+ output: number;
39
+ reasoning: number;
40
+ cacheRead: number;
41
+ }
42
+
43
+ function calcMetrics(d: PassiveUsageResult | null): SummaryMetrics {
44
+ if (!d || d.session_count === 0)
45
+ return {
46
+ sessions: 0,
47
+ cacheHitRate: 0,
48
+ reasoningPct: 0,
49
+ outputRatio: 0,
50
+ avgPerSession: 0,
51
+ totalCost: 0,
52
+ input: 0,
53
+ output: 0,
54
+ reasoning: 0,
55
+ cacheRead: 0,
56
+ };
57
+ const totalCache = d.total_tokens_cache_read + d.total_tokens_cache_write;
58
+ const contextTokens = d.total_tokens_input + d.total_tokens_output;
59
+ return {
60
+ sessions: d.session_count,
61
+ cacheHitRate: totalCache > 0 ? d.total_tokens_cache_read / totalCache : 0,
62
+ reasoningPct:
63
+ contextTokens > 0 ? d.total_tokens_reasoning / contextTokens : 0,
64
+ outputRatio:
65
+ d.total_tokens_input + d.total_tokens_output > 0
66
+ ? d.total_tokens_output / (d.total_tokens_input + d.total_tokens_output)
67
+ : 0,
68
+ avgPerSession:
69
+ d.session_count > 0
70
+ ? Math.round(
71
+ (d.total_tokens_input +
72
+ d.total_tokens_output +
73
+ d.total_tokens_reasoning) /
74
+ d.session_count,
75
+ )
76
+ : 0,
77
+ totalCost: d.total_cost,
78
+ input: d.total_tokens_input,
79
+ output: d.total_tokens_output,
80
+ reasoning: d.total_tokens_reasoning,
81
+ cacheRead: d.total_tokens_cache_read,
82
+ };
83
+ }
84
+
85
+ function pct(n: number): string {
86
+ return `${(n * 100).toFixed(1)}%`;
87
+ }
88
+
89
+ /** Shorten a worktree path to its basename for display. */
90
+ function shortWt(wt: string): string {
91
+ const parts = wt.replace(/\/$/, "").split("/");
92
+ return parts[parts.length - 1] || wt;
93
+ }
94
+
95
+ function barHtml(value: number, max: number, color: string): string {
96
+ const w = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0;
97
+ return `<div class="bar"><div class="bar-fill" style="width:${w}%;background:${color}"></div></div>`;
98
+ }
99
+
100
+ function metric(
101
+ label: string,
102
+ value: string,
103
+ cls: string,
104
+ bar: string,
105
+ wide = false,
106
+ ): string {
107
+ return `<div class="card-metric${wide ? " wide" : ""}">
108
+ <div class="metric-label">${label}</div>
109
+ <div class="metric-value ${cls}">${value}</div>
110
+ ${bar}
111
+ </div>`;
112
+ }
113
+
114
+ // caller filters m.sessions === 0 out before calling — a card with no
115
+ // sessions has nothing to show, so it's hidden rather than rendered empty
116
+ function summaryCard(name: string, color: string, m: SummaryMetrics): string {
117
+ const maxInput = Math.max(m.input, m.output, m.reasoning, 1);
118
+ return `<div class="card" style="border-left-color:${color}">
119
+ <div class="card-title" style="color:${color}">${name} <span class="sample">(${m.sessions} sessions)</span></div>
120
+ <div class="card-metrics">
121
+ ${metric("Cache Hit", pct(m.cacheHitRate), m.cacheHitRate > 0.7 ? "pass" : "warn", barHtml(m.cacheHitRate, 1, m.cacheHitRate > 0.7 ? "var(--green)" : "var(--yellow)"))}
122
+ ${metric("Reasoning", pct(m.reasoningPct), m.reasoningPct > 0.15 ? "warn" : "pass", barHtml(m.reasoningPct, 1, m.reasoningPct > 0.15 ? "var(--red)" : "var(--green)"))}
123
+ ${metric("Output", pct(m.outputRatio), "", barHtml(m.outputRatio, 1, m.outputRatio > 0.3 ? "var(--green)" : "var(--yellow)"))}
124
+ ${metric("Input", fmtTokens(m.input), "", barHtml(m.input, maxInput, "var(--accent)"))}
125
+ ${metric("Output", fmtTokens(m.output), "", barHtml(m.output, maxInput, "var(--green)"))}
126
+ ${metric("Reasoning", fmtTokens(m.reasoning), m.reasoningPct > 0.15 ? "warn" : "", barHtml(m.reasoning, maxInput, "var(--yellow)"))}
127
+ ${metric("Cache Read", fmtTokens(m.cacheRead), "", "")}
128
+ ${metric("Avg/Session", fmtTokens(m.avgPerSession), "", "")}
129
+ ${metric("Cost", fmtCost(m.totalCost), "", "", true)}
130
+ </div>
131
+ </div>`;
132
+ }
133
+
134
+ function cell(
135
+ d: ModelBreakdown | undefined,
136
+ field: keyof ModelBreakdown,
137
+ ): string {
138
+ if (!d) return '<td class="muted">\u2014</td>';
139
+ const v = d[field];
140
+ if (typeof v !== "number") return '<td class="muted">\u2014</td>';
141
+ if (field === "cost") return `<td>${fmtCost(v)}</td>`;
142
+ return `<td>${fmtTokens(v)}</td>`;
143
+ }
144
+
145
+ function modelRows(data: PassiveUsageResult | null): string {
146
+ if (!data || data.by_model.length === 0)
147
+ return ' <tr><td class="muted" colspan="9">no sessions</td></tr>';
148
+ return data.by_model
149
+ .sort(
150
+ (a, b) =>
151
+ b.tokens_input +
152
+ b.tokens_output +
153
+ b.tokens_reasoning +
154
+ b.tokens_cache_read +
155
+ b.tokens_cache_write -
156
+ (a.tokens_input +
157
+ a.tokens_output +
158
+ a.tokens_reasoning +
159
+ a.tokens_cache_read +
160
+ a.tokens_cache_write),
161
+ )
162
+ .map(
163
+ (m) =>
164
+ ` <tr data-model="${esc(m.model)}">
165
+ <td class="model-name">${esc(shortModel(m.model))}</td>
166
+ <td class="muted">${esc(m.provider || "\u2014")}</td>
167
+ ${FIELDS.map((f) => cell(m, f)).join("")}
168
+ </tr>`,
169
+ )
170
+ .join("\n");
171
+ }
172
+
173
+ function totalsRow(data: PassiveUsageResult | null): string {
174
+ if (!data || data.session_count === 0)
175
+ return ' <tr class="totals"><td>Totals</td><td class="muted" colspan="8">no data</td></tr>';
176
+ return ` <tr class="totals">
177
+ <td>Totals</td>
178
+ <td></td>
179
+ <td>${fmtTokens(data.total_tokens_input)}</td>
180
+ <td>${fmtTokens(data.total_tokens_output)}</td>
181
+ <td>${fmtTokens(data.total_tokens_cache_read)}</td>
182
+ <td>${fmtTokens(data.total_tokens_cache_write)}</td>
183
+ <td>${fmtTokens(data.total_tokens_reasoning)}</td>
184
+ <td>${data.session_count}</td>
185
+ <td>${fmtCost(data.total_cost)}</td>
186
+ </tr>`;
187
+ }
188
+
189
+ /**
190
+ * Badge for a client whose log could not be read. Without it the row is zeros,
191
+ * which reads as "never used" — the wrong conclusion, and an invisible one.
192
+ */
193
+ function errorBadge(data: PassiveUsageResult | null): string {
194
+ if (!data?.error) return "";
195
+ return ` <span class="read-error" title="fapony could not read this client's session log \u2014 usually schema drift after a client update">could not read: ${esc(data.error)}</span>`;
196
+ }
197
+
198
+ function clientTable(
199
+ id: string,
200
+ name: string,
201
+ color: string,
202
+ data: PassiveUsageResult | null,
203
+ note = "",
204
+ ): string {
205
+ return `
206
+ <h2 style="color:${color}">${name} <span class="sample">(${data?.session_count ?? 0} sessions)</span>${errorBadge(data)}</h2>
207
+ <table id="${id}">
208
+ <thead>
209
+ <tr>
210
+ <th>Model</th>
211
+ ${HEADERS.map((h) => `<th>${h}</th>`).join("")}
212
+ </tr>
213
+ </thead>
214
+ <tbody>
215
+ ${modelRows(data)}
216
+ </tbody>
217
+ <tfoot>
218
+ ${totalsRow(data)}
219
+ </tfoot>
220
+ </table>${note ? `\n<div class="meta">${note}</div>` : ""}`;
221
+ }
222
+
223
+ function freshnessBar(scannedAt: string): string {
224
+ const ageMs = Date.now() - new Date(scannedAt).getTime();
225
+ const ageDays = ageMs / 86400000;
226
+ let dotClass: string;
227
+ let ageLabel: string;
228
+
229
+ if (ageDays < 1) {
230
+ dotClass = "fresh";
231
+ const ageH = ageMs / 3600000;
232
+ ageLabel =
233
+ ageH < 1
234
+ ? `${Math.round(ageMs / 60000)}m ago`
235
+ : `${ageH.toFixed(1)}h ago`;
236
+ } else if (ageDays < 7) {
237
+ dotClass = "stale";
238
+ ageLabel = `${ageDays.toFixed(1)}d ago`;
239
+ } else {
240
+ dotClass = "stale warn";
241
+ ageLabel = `${Math.round(ageDays)}d ago`;
242
+ }
243
+
244
+ return `<div class="meta">
245
+ <span class="status ${dotClass}"></span>
246
+ <span>data as of <strong>${new Date(scannedAt).toLocaleString()}</strong> (${ageLabel})</span>
247
+ <span>\u00b7</span>
248
+ <span><code>fapony usage-scan</code> to update</span>
249
+ </div>`;
250
+ }
251
+
252
+ type ClientData = {
253
+ opencode: PassiveUsageResult;
254
+ zcode: PassiveUsageResult;
255
+ claude_code: PassiveUsageResult;
256
+ codex: PassiveUsageResult;
257
+ };
258
+
259
+ /** Context-share bar: proportion of input+output tokens per client. */
260
+ function shareSection(
261
+ oc: SummaryMetrics,
262
+ zc: SummaryMetrics,
263
+ cc: SummaryMetrics,
264
+ cx: SummaryMetrics,
265
+ ): string {
266
+ const ctxTokens = [
267
+ { name: "OpenCode", color: "var(--green)", tokens: oc.input + oc.output },
268
+ { name: "ZCode", color: "var(--accent)", tokens: zc.input + zc.output },
269
+ {
270
+ name: "Claude Code",
271
+ color: "var(--yellow)",
272
+ tokens: cc.input + cc.output,
273
+ },
274
+ { name: "Codex", color: "var(--accent)", tokens: cx.input + cx.output },
275
+ ];
276
+ const ctxTotal = ctxTokens.reduce((s, c) => s + c.tokens, 0);
277
+
278
+ return `<div class="share-section">
279
+ <div class="share-title">context share (tokens)</div>
280
+ <div class="share-bar">
281
+ ${
282
+ ctxTotal > 0
283
+ ? ctxTokens
284
+ .map((c) => {
285
+ const w = Math.round((c.tokens / ctxTotal) * 100);
286
+ return w > 0
287
+ ? `<div class="share-seg" style="width:${w}%;background:${c.color}" title="${c.name}: ${fmtTokens(c.tokens)}"></div>`
288
+ : "";
289
+ })
290
+ .join("")
291
+ : '<div class="share-seg" style="width:100%;background:var(--border)"></div>'
292
+ }
293
+ </div>
294
+ <div class="share-legend">
295
+ ${ctxTokens
296
+ .map((c) => {
297
+ const pctStr =
298
+ ctxTotal > 0 ? ((c.tokens / ctxTotal) * 100).toFixed(1) : "0.0";
299
+ return `<span class="share-item"><span class="share-dot" style="background:${c.color}"></span>${c.name} ${pctStr}%</span>`;
300
+ })
301
+ .join("")}
302
+ </div>
303
+ </div>`;
304
+ }
305
+
306
+ /**
307
+ * เติมราคาตั้งให้แถวที่ client ไม่บันทึก cost (0) — ของที่มีราคาจริงอยู่แล้ว
308
+ * ไม่แตะ · คืน view ไว้ render + note ไว้ใต้ตาราง (ป้าย list-price ทุกจุด)
309
+ */
310
+ function withImputed(
311
+ data: PassiveUsageResult | null,
312
+ prices: PriceTable | null,
313
+ ): { view: PassiveUsageResult | null; note: string } {
314
+ if (!data || data.session_count === 0) return { view: data, note: "" };
315
+ if (!prices)
316
+ return {
317
+ view: data,
318
+ note: "list-price equivalent unavailable \u2014 run <code>fapony price-scan</code>",
319
+ };
320
+ const s = imputeResult(data, prices);
321
+ const realByKey = new Map(
322
+ data.by_model.map((m) => [`${m.provider}\0${m.model}`, m]),
323
+ );
324
+ const byModel = s.by_model.map((m) => {
325
+ const real = realByKey.get(`${m.provider}\0${m.model}`);
326
+ return {
327
+ provider: m.provider,
328
+ model: m.model,
329
+ session_count: m.session_count,
330
+ tokens_input: m.tokens_input,
331
+ tokens_output: m.tokens_output,
332
+ tokens_reasoning: real?.tokens_reasoning ?? 0,
333
+ tokens_cache_read: m.tokens_cache_read,
334
+ tokens_cache_write: m.tokens_cache_write,
335
+ // รวมกับ cost จริงรายรุ่น (opencode บันทึกเอง) — ข้างไหนมีค่ากว่ากันเอาข้างนั้น
336
+ cost: real && real.cost > 0 ? real.cost : m.imputed_cost,
337
+ };
338
+ });
339
+ const realTotal = data.total_cost > 0 ? data.total_cost : s.total_imputed;
340
+ const parts = [
341
+ `~$${s.total_imputed.toFixed(4)} over ${s.priced_sessions} priced sessions`,
342
+ ];
343
+ if (s.free_sessions > 0) parts.push(`${s.free_sessions} free`);
344
+ if (s.unpriced_sessions > 0)
345
+ parts.push(
346
+ `unpriced: ${s.unpriced_sessions} sessions (${fmtTokens(s.unpriced_tokens)} tokens)`,
347
+ );
348
+ return {
349
+ view: { ...data, total_cost: realTotal, by_model: byModel },
350
+ note: `list-price equivalent: ${parts.join(" \u00b7 ")} \u00b7 prices ${esc(prices.fetched_at.slice(0, 10))} \u2014 run <code>fapony price-scan</code> to refresh`,
351
+ };
352
+ }
353
+
354
+ export function renderUsageHtml(
355
+ projectData: Map<string, ClientData>,
356
+ scannedAt: string,
357
+ ownerName?: string,
358
+ prices?: PriceTable | null,
359
+ ): string {
360
+ // Find the global entry (worktree=null) and per-project entries.
361
+ const globalData = projectData.get("__global__");
362
+ const projectKeys = [...projectData.keys()]
363
+ .filter((k) => k !== "__global__")
364
+ .sort();
365
+
366
+ // ราคา list จาก cache อย่างเดียว — serve ไม่ fetch เอง (offline ได้)
367
+ const table = prices === undefined ? loadPrices() : prices;
368
+
369
+ const owner = ownerName?.trim() ? esc(ownerName.trim()) : "";
370
+
371
+ function projectSection(
372
+ label: string,
373
+ data: ClientData,
374
+ isGlobal: boolean,
375
+ ): string {
376
+ const oc = withImputed(data.opencode, table);
377
+ const zc = withImputed(data.zcode, table);
378
+ const cc = withImputed(data.claude_code, table);
379
+ const cx = withImputed(data.codex, table);
380
+ const pOc = calcMetrics(oc.view);
381
+ const pZc = calcMetrics(zc.view);
382
+ const pCc = calcMetrics(cc.view);
383
+ const pCx = calcMetrics(cx.view);
384
+ const totalSessions =
385
+ pOc.sessions + pZc.sessions + pCc.sessions + pCx.sessions;
386
+ // A project with no sessions is noise — unless the reason it has none is
387
+ // that every client failed to read, which is exactly what must be shown.
388
+ const anyError = [
389
+ data.opencode,
390
+ data.zcode,
391
+ data.claude_code,
392
+ data.codex,
393
+ ].some((d) => d?.error);
394
+ if (totalSessions === 0 && !isGlobal && !anyError) return "";
395
+
396
+ const heading = isGlobal ? "All projects" : shortWt(label);
397
+
398
+ // การ์ดที่ไม่มี session เลยไม่มีอะไรให้ดู — ซ่อนแทนที่จะโชว์ "no sessions"
399
+ const cards = [
400
+ ["OpenCode", "var(--green)", pOc],
401
+ ["ZCode", "var(--accent)", pZc],
402
+ ["Claude Code", "var(--yellow)", pCc],
403
+ ["Codex", "var(--accent)", pCx],
404
+ ] as const;
405
+
406
+ return `
407
+ <h2 style="color:var(--accent)">${esc(heading)} <span class="sample">(${totalSessions} sessions)</span></h2>
408
+ <div class="cards">
409
+ ${cards
410
+ .filter(([, , m]) => m.sessions > 0)
411
+ .map(([name, color, m]) => summaryCard(name, color, m))
412
+ .join("\n")}
413
+ </div>
414
+
415
+ ${shareSection(pOc, pZc, pCc, pCx)}
416
+
417
+ ${clientTable(`t-oc-${label}`, "OpenCode", "var(--green)", oc.view, oc.note)}
418
+ ${clientTable(`t-zc-${label}`, "ZCode", "var(--accent)", zc.view, zc.note)}
419
+ ${clientTable(`t-cc-${label}`, "Claude Code", "var(--yellow)", cc.view, cc.note)}
420
+ ${clientTable(`t-cx-${label}`, "Codex", "var(--accent)", cx.view, cx.note)}`;
421
+ }
422
+
423
+ // Project navigation (when there are multiple projects).
424
+ const navHtml =
425
+ projectKeys.length > 1
426
+ ? `<div class="meta" style="margin-bottom:0.5rem">
427
+ ${projectKeys.map((k) => `<a href="#proj-${esc(k)}" style="color:var(--accent);text-decoration:none">${esc(shortWt(k))}</a>`).join(" · ")}
428
+ ${globalData ? ` · <a href="#proj-all" style="color:var(--accent);text-decoration:none;font-weight:600">all</a>` : ""}
429
+ </div>`
430
+ : "";
431
+
432
+ // Render all project sections.
433
+ const sections: string[] = [];
434
+
435
+ // Global section first (if exists).
436
+ if (globalData) {
437
+ sections.push(
438
+ `<div id="proj-all">${projectSection("all", globalData, true)}</div>`,
439
+ );
440
+ }
441
+
442
+ // Per-project sections.
443
+ for (const k of projectKeys) {
444
+ const data = projectData.get(k)!;
445
+ sections.push(
446
+ `<div id="proj-${esc(k)}">${projectSection(k, data, false)}</div>`,
447
+ );
448
+ }
449
+
450
+ return `<!DOCTYPE html>
451
+ <html lang="en">
452
+ <head>
453
+ <meta charset="utf-8">
454
+ <meta name="viewport" content="width=device-width, initial-scale=1">
455
+ <title>fapony usage${owner ? ` \u2014 ${owner}` : ""}</title>
456
+ <style>
457
+ :root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922; --muted: #8b949e; }
458
+ * { box-sizing: border-box; margin: 0; padding: 0; }
459
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; padding: 2rem; max-width: 960px; margin: 0 auto; }
460
+ .header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
461
+ .owner { color: var(--muted); font-size: 0.9rem; font-weight: 400; white-space: nowrap; }
462
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
463
+ h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
464
+ .meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
465
+ .status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
466
+ .status.fresh { background: var(--green); }
467
+ .status.stale { background: var(--yellow); }
468
+ .status.stale.warn { background: var(--red); }
469
+ .sample { font-size: 0.8rem; color: var(--muted); font-weight: 400; }
470
+ table { width: 100%; border-collapse: collapse; margin-bottom: 0.5rem; font-size: 0.85rem; }
471
+ th, td { padding: 0.4rem 0.6rem; text-align: right; border-bottom: 1px solid var(--border); white-space: nowrap; }
472
+ th { color: var(--muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; position: sticky; top: 0; background: var(--bg); }
473
+ td:first-child, th:first-child { text-align: left; }
474
+ td.model-name { font-weight: 600; color: var(--fg); }
475
+ tr:hover { background: #161b22; }
476
+ .muted { color: var(--muted); }
477
+ .totals { font-weight: 700; background: #161b22; }
478
+ .totals td { border-top: 2px solid var(--border); }
479
+ .pass { color: var(--green); }
480
+ .warn { color: var(--yellow); }
481
+ .cards { display: flex; flex-direction: column; gap: 0.8rem; margin-bottom: 1.5rem; }
482
+ .card { background: #161b22; border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 6px; padding: 0.8rem 1rem; }
483
+ .card-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; }
484
+ .card-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; }
485
+ .card-metric { font-size: 0.8rem; }
486
+ .card-metric.wide { grid-column: span 2; }
487
+ .metric-label { color: var(--muted); font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.03em; }
488
+ .metric-value { font-size: 0.95rem; font-weight: 700; color: var(--fg); }
489
+ .bar { height: 3px; background: var(--border); border-radius: 2px; margin-top: 0.15rem; overflow: hidden; }
490
+ .bar-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
491
+ .share-section { margin-bottom: 1.5rem; }
492
+ .read-error { font-size: 0.7rem; font-weight: 600; color: var(--red); border: 1px solid var(--red); border-radius: 4px; padding: 0.1rem 0.4rem; vertical-align: middle; }
493
+ .share-title { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.4rem; }
494
+ .share-bar { display: flex; height: 20px; border-radius: 4px; overflow: hidden; background: var(--border); }
495
+ .share-seg { height: 100%; transition: width 0.3s; min-width: 1px; }
496
+ .share-legend { display: flex; gap: 1rem; margin-top: 0.3rem; flex-wrap: wrap; }
497
+ .share-item { font-size: 0.75rem; color: var(--fg); display: flex; align-items: center; gap: 0.3rem; }
498
+ .share-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
499
+ .footer { margin-top: 1.5rem; font-size: 0.8rem; color: var(--muted); text-align: center; }
500
+ </style>
501
+ </head>
502
+ <body>
503
+
504
+ <div class="header">
505
+ <h1>fapony usage</h1>
506
+ ${owner ? `<span class="owner">${owner}</span>` : ""}
507
+ </div>
508
+ ${freshnessBar(scannedAt)}
509
+
510
+ ${navHtml}
511
+
512
+ ${sections.join("\n")}
513
+
514
+ <div class="footer">
515
+ Tokens and cost come from each client's own session log — ZCode, Claude Code and Codex record no cost, so theirs reads $0.
516
+ Costs marked list-price equivalent are OpenRouter list rates over tokens already logged (not money paid) — unpriced models are listed, never folded into $0.
517
+ <br>
518
+ Run <code>fapony usage-scan</code> to refresh data.
519
+ </div>
520
+
521
+ </body>
522
+ </html>`;
523
+ }