@mohndoe/pi-atlas 0.1.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/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +30 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
// ---- Language detection ----
|
|
4
|
+
|
|
5
|
+
export const EXT_TO_LANG: Record<string, string> = {
|
|
6
|
+
astro: "Astro",
|
|
7
|
+
bash: "Shell",
|
|
8
|
+
c: "C",
|
|
9
|
+
cc: "C++",
|
|
10
|
+
cjs: "JavaScript",
|
|
11
|
+
clj: "Clojure",
|
|
12
|
+
cljs: "Clojure",
|
|
13
|
+
coffee: "CoffeeScript",
|
|
14
|
+
cpp: "C++",
|
|
15
|
+
cr: "Crystal",
|
|
16
|
+
cs: "C#",
|
|
17
|
+
css: "CSS",
|
|
18
|
+
cts: "TypeScript",
|
|
19
|
+
dart: "Dart",
|
|
20
|
+
dockerfile: "Dockerfile",
|
|
21
|
+
ejs: "EJS",
|
|
22
|
+
elm: "Elm",
|
|
23
|
+
env: "Env",
|
|
24
|
+
erl: "Erlang",
|
|
25
|
+
ex: "Elixir",
|
|
26
|
+
exs: "Elixir",
|
|
27
|
+
fs: "F#",
|
|
28
|
+
fsi: "F#",
|
|
29
|
+
fsx: "F#",
|
|
30
|
+
gitignore: "Gitignore",
|
|
31
|
+
gleam: "Gleam",
|
|
32
|
+
go: "Go",
|
|
33
|
+
gql: "GraphQL",
|
|
34
|
+
graphql: "GraphQL",
|
|
35
|
+
graphqls: "GraphQL",
|
|
36
|
+
groovy: "Groovy",
|
|
37
|
+
h: "C",
|
|
38
|
+
hbs: "Handlebars",
|
|
39
|
+
hpp: "C++",
|
|
40
|
+
hs: "Haskell",
|
|
41
|
+
htm: "HTML",
|
|
42
|
+
html: "HTML",
|
|
43
|
+
"html.j2": "Jinja",
|
|
44
|
+
j2: "Jinja",
|
|
45
|
+
java: "Java",
|
|
46
|
+
jinja: "Jinja",
|
|
47
|
+
jinja2: "Jinja",
|
|
48
|
+
jl: "Julia",
|
|
49
|
+
js: "JavaScript",
|
|
50
|
+
json: "JSON",
|
|
51
|
+
jsx: "JavaScript",
|
|
52
|
+
kt: "Kotlin",
|
|
53
|
+
kts: "Kotlin",
|
|
54
|
+
less: "Less",
|
|
55
|
+
liquid: "Liquid",
|
|
56
|
+
lua: "Lua",
|
|
57
|
+
m: "Objective-C",
|
|
58
|
+
md: "Markdown",
|
|
59
|
+
mdx: "Markdown",
|
|
60
|
+
mjs: "JavaScript",
|
|
61
|
+
mm: "Objective-C",
|
|
62
|
+
mts: "TypeScript",
|
|
63
|
+
mustache: "Mustache",
|
|
64
|
+
nix: "Nix",
|
|
65
|
+
njk: "Nunjucks",
|
|
66
|
+
php: "PHP",
|
|
67
|
+
phtml: "PHP",
|
|
68
|
+
pl: "Perl",
|
|
69
|
+
pm: "Perl",
|
|
70
|
+
prisma: "Prisma",
|
|
71
|
+
proto: "Protobuf",
|
|
72
|
+
ps1: "PowerShell",
|
|
73
|
+
psd1: "PowerShell",
|
|
74
|
+
psm1: "PowerShell",
|
|
75
|
+
pug: "Pug",
|
|
76
|
+
py: "Python",
|
|
77
|
+
pyi: "Python",
|
|
78
|
+
qml: "QML",
|
|
79
|
+
r: "R",
|
|
80
|
+
raku: "Raku",
|
|
81
|
+
rb: "Ruby",
|
|
82
|
+
rs: "Rust",
|
|
83
|
+
scala: "Scala",
|
|
84
|
+
scss: "SCSS",
|
|
85
|
+
sh: "Shell",
|
|
86
|
+
sol: "Solidity",
|
|
87
|
+
sql: "SQL",
|
|
88
|
+
styl: "Stylus",
|
|
89
|
+
svelte: "Svelte",
|
|
90
|
+
swift: "Swift",
|
|
91
|
+
tf: "Terraform",
|
|
92
|
+
toml: "TOML",
|
|
93
|
+
ts: "TypeScript",
|
|
94
|
+
tsx: "TypeScript",
|
|
95
|
+
twig: "Twig",
|
|
96
|
+
vue: "Vue",
|
|
97
|
+
wasm: "WebAssembly",
|
|
98
|
+
wat: "WebAssembly",
|
|
99
|
+
xml: "XML",
|
|
100
|
+
yaml: "YAML",
|
|
101
|
+
yml: "YAML",
|
|
102
|
+
zig: "Zig",
|
|
103
|
+
zsh: "Shell",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function langFromPath(path: string): string {
|
|
107
|
+
const ext = basename(path).split(".").pop()?.toLowerCase() ?? "";
|
|
108
|
+
return EXT_TO_LANG[ext] ?? "Other";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- Project name extraction ----
|
|
112
|
+
|
|
113
|
+
export function projectNameFromCwd(cwd: string): string {
|
|
114
|
+
return basename(cwd);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Date utilities ----
|
|
118
|
+
|
|
119
|
+
export function dateFromISOString(str: string): string {
|
|
120
|
+
return str.slice(0, 10);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Month / day constants ----
|
|
124
|
+
|
|
125
|
+
export const MONTH_NAMES = [
|
|
126
|
+
"Jan",
|
|
127
|
+
"Feb",
|
|
128
|
+
"Mar",
|
|
129
|
+
"Apr",
|
|
130
|
+
"May",
|
|
131
|
+
"Jun",
|
|
132
|
+
"Jul",
|
|
133
|
+
"Aug",
|
|
134
|
+
"Sep",
|
|
135
|
+
"Oct",
|
|
136
|
+
"Nov",
|
|
137
|
+
"Dec",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
// ---- Number formatting ----
|
|
141
|
+
|
|
142
|
+
export function formatNumber(n: number): string {
|
|
143
|
+
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
|
|
144
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
|
145
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
|
146
|
+
return String(n);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatCost(n: number): string {
|
|
150
|
+
if (n >= 1_000_000) return "$" + (n / 1_000_000).toFixed(1) + "M";
|
|
151
|
+
if (n >= 1_000) return "$" + (n / 1_000).toFixed(1) + "k";
|
|
152
|
+
return "$" + n.toFixed(2);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- Timestamp formatting ----
|
|
156
|
+
|
|
157
|
+
function localDatePart(d: Date): string {
|
|
158
|
+
const y = d.getFullYear();
|
|
159
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
160
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
161
|
+
return `${y}-${m}-${day}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatTime(d: Date): string {
|
|
165
|
+
const h = d.getHours();
|
|
166
|
+
const m = d.getMinutes();
|
|
167
|
+
const ampm = h >= 12 ? "PM" : "AM";
|
|
168
|
+
const h12 = h % 12 || 12;
|
|
169
|
+
return `${h12}:${m.toString().padStart(2, "0")} ${ampm}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function formatCacheTimestamp(iso: string): string {
|
|
173
|
+
const d = new Date(iso);
|
|
174
|
+
const time = formatTime(d);
|
|
175
|
+
|
|
176
|
+
const now = new Date();
|
|
177
|
+
const today = localDatePart(now);
|
|
178
|
+
const yesterday = localDatePart(new Date(now.getTime() - 86400000));
|
|
179
|
+
const thisYear = now.getFullYear();
|
|
180
|
+
|
|
181
|
+
const dayPart = localDatePart(d);
|
|
182
|
+
|
|
183
|
+
if (dayPart === today) {
|
|
184
|
+
return time;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (dayPart === yesterday) {
|
|
188
|
+
return `Yesterday ${time}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const month = MONTH_NAMES[d.getMonth()];
|
|
192
|
+
const day = d.getDate();
|
|
193
|
+
|
|
194
|
+
if (d.getFullYear() === thisYear) {
|
|
195
|
+
return `${month} ${day}, ${time}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return `${month} ${day}, ${d.getFullYear()}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- Model name formatting ----
|
|
202
|
+
|
|
203
|
+
/** Strip ANSI escape sequences and control characters that can break terminal rendering. */
|
|
204
|
+
export function stripAnsi(text: string): string {
|
|
205
|
+
// First strip ANSI sequences
|
|
206
|
+
let clean = text.replace(
|
|
207
|
+
/[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]|(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))/g,
|
|
208
|
+
"",
|
|
209
|
+
);
|
|
210
|
+
// Then strip control characters that can break terminal rendering
|
|
211
|
+
return clean.replace(/[\x00-\x08\x0A-\x1F\x7F\u200B-\u200F\u2028-\u2029\uFEFF]/g, "");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function formatModelName(raw: string): string {
|
|
215
|
+
// Strip date suffix (YYYYMMDD or YYYY-MM-DD)
|
|
216
|
+
let name = raw.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
|
217
|
+
// Replace separators with spaces, title case each word
|
|
218
|
+
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
219
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getCacheTimestamp, loadAggregate } from "./cache";
|
|
5
|
+
import { Dashboard } from "./components/Dashboard";
|
|
6
|
+
import { LoadingView } from "./components/LoadingView";
|
|
7
|
+
import { summarize } from "./compute";
|
|
8
|
+
import { formatCacheTimestamp } from "./format";
|
|
9
|
+
import type { TimeRange } from "./types";
|
|
10
|
+
import { RangeSelector, type RangeOption } from "./components/RangeSelector";
|
|
11
|
+
|
|
12
|
+
const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
|
|
13
|
+
const CACHE_PATH = join(homedir(), ".pi", "pi-atlas-cache.json");
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI) {
|
|
16
|
+
pi.registerCommand("atlas", {
|
|
17
|
+
description: "Show Pi Atlas usage dashboard",
|
|
18
|
+
handler: async (_args, ctx) => {
|
|
19
|
+
if (!ctx.hasUI) {
|
|
20
|
+
ctx.ui.notify("Stats dashboard requires interactive mode", "error");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const overlayOpts = {
|
|
25
|
+
overlay: true as const,
|
|
26
|
+
overlayOptions: {
|
|
27
|
+
minWidth: 100,
|
|
28
|
+
width: "50%" as const,
|
|
29
|
+
maxHeight: "80%" as const,
|
|
30
|
+
anchor: "center" as const,
|
|
31
|
+
margin: 2,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Read last update timestamp before loading (cache may be rewritten)
|
|
36
|
+
const lastUpdate = await getCacheTimestamp(CACHE_PATH);
|
|
37
|
+
const updateLabel = lastUpdate ? `Last update : ${formatCacheTimestamp(lastUpdate)}` : null;
|
|
38
|
+
|
|
39
|
+
// Phase 1: Show loading, parse session logs
|
|
40
|
+
let days: Awaited<ReturnType<typeof loadAggregate>>;
|
|
41
|
+
try {
|
|
42
|
+
days = await ctx.ui.custom<Awaited<ReturnType<typeof loadAggregate>>>(
|
|
43
|
+
(tui, _theme, _kb, done) => {
|
|
44
|
+
const loadingView = new LoadingView("Parsing session logs...", tui);
|
|
45
|
+
|
|
46
|
+
loadAggregate(CACHE_PATH, SESSIONS_DIR, false, (p) => {
|
|
47
|
+
loadingView.setProgress(p);
|
|
48
|
+
tui.requestRender();
|
|
49
|
+
})
|
|
50
|
+
.then((result) => done(result))
|
|
51
|
+
.catch(() => done([]));
|
|
52
|
+
|
|
53
|
+
return loadingView;
|
|
54
|
+
},
|
|
55
|
+
overlayOpts,
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
ctx.ui.notify("Failed to parse session logs", "error");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Phase 2: Show dashboard (handles empty state internally)
|
|
63
|
+
const rangesToSummarize: TimeRange[] = ["1d", "7d", "30d", "All"];
|
|
64
|
+
const summaries = new Map(rangesToSummarize.map((r) => [r, summarize(days, r)] as const));
|
|
65
|
+
|
|
66
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
67
|
+
const rangeOptions: RangeOption[] = [
|
|
68
|
+
{ label: "Today", value: "1d" },
|
|
69
|
+
{ label: "Last 7 days", value: "7d" },
|
|
70
|
+
{ label: "Last 30 days", value: "30d" },
|
|
71
|
+
{ label: "All time", value: "All" },
|
|
72
|
+
];
|
|
73
|
+
const rangeSelector = new RangeSelector(theme, rangeOptions, rangeOptions.length - 1);
|
|
74
|
+
const dashboard = new Dashboard(summaries, theme, tui, updateLabel, rangeSelector, () =>
|
|
75
|
+
done(undefined),
|
|
76
|
+
);
|
|
77
|
+
return {
|
|
78
|
+
render: (w: number) => dashboard.render(w),
|
|
79
|
+
handleInput: (d: string) => {
|
|
80
|
+
dashboard.handleInput(d);
|
|
81
|
+
tui.requestRender();
|
|
82
|
+
},
|
|
83
|
+
invalidate: () => dashboard.invalidate(),
|
|
84
|
+
};
|
|
85
|
+
}, overlayOpts);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import type {
|
|
4
|
+
CompactionEntry,
|
|
5
|
+
FileEntry,
|
|
6
|
+
ModelChangeEntry,
|
|
7
|
+
SessionHeader,
|
|
8
|
+
SessionMessageEntry,
|
|
9
|
+
ThinkingLevelChangeEntry,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
import { dateFromISOString, langFromPath, projectNameFromCwd } from "./format";
|
|
14
|
+
import type { DayAgg } from "./types";
|
|
15
|
+
|
|
16
|
+
/** Strip control characters (\n, \r, \t, etc.) from a tool name. */
|
|
17
|
+
function sanitizeToolName(name: string): string {
|
|
18
|
+
// Remove any character below 0x20 (control chars) except 0x09 (\t) which
|
|
19
|
+
// we also strip, plus 0x7F (DEL) and Unicode general category Cc/Cf.
|
|
20
|
+
return name.replace(/[\x00-\x08\x0A-\x1F\x7F\u200B-\u200F\u2028-\u2029\uFEFF]/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Tracks session ID → project name for cost attribution
|
|
24
|
+
const sessionProjectMap = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
export { sessionProjectMap };
|
|
27
|
+
|
|
28
|
+
export function emptyDay(date: string): DayAgg {
|
|
29
|
+
return {
|
|
30
|
+
date,
|
|
31
|
+
cost: 0,
|
|
32
|
+
hourCost: {},
|
|
33
|
+
inTok: 0,
|
|
34
|
+
outTok: 0,
|
|
35
|
+
crTok: 0,
|
|
36
|
+
cwTok: 0,
|
|
37
|
+
userMsgs: 0,
|
|
38
|
+
asstMsgs: 0,
|
|
39
|
+
toolResults: 0,
|
|
40
|
+
sessionIds: new Set(),
|
|
41
|
+
langLines: {},
|
|
42
|
+
langEdits: {},
|
|
43
|
+
modelCost: {},
|
|
44
|
+
modelCount: {},
|
|
45
|
+
providerCost: {},
|
|
46
|
+
providerCount: {},
|
|
47
|
+
modelToProvider: new Map(),
|
|
48
|
+
projectCost: {},
|
|
49
|
+
projectSessions: {},
|
|
50
|
+
toolCount: {},
|
|
51
|
+
compactionCount: 0,
|
|
52
|
+
compactedTokens: 0,
|
|
53
|
+
modelChanges: 0,
|
|
54
|
+
thinkingLevelCount: {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mergeDay(base: DayAgg, update: DayAgg): void {
|
|
59
|
+
base.cost += update.cost;
|
|
60
|
+
base.inTok += update.inTok;
|
|
61
|
+
base.outTok += update.outTok;
|
|
62
|
+
base.crTok += update.crTok;
|
|
63
|
+
base.cwTok += update.cwTok;
|
|
64
|
+
base.userMsgs += update.userMsgs;
|
|
65
|
+
base.asstMsgs += update.asstMsgs;
|
|
66
|
+
base.toolResults += update.toolResults;
|
|
67
|
+
|
|
68
|
+
for (const id of update.sessionIds) base.sessionIds.add(id);
|
|
69
|
+
|
|
70
|
+
for (const [h, c] of Object.entries(update.hourCost)) {
|
|
71
|
+
base.hourCost[Number(h)] = (base.hourCost[Number(h)] ?? 0) + c;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [k, v] of Object.entries(update.langLines)) {
|
|
75
|
+
base.langLines[k] = (base.langLines[k] ?? 0) + v;
|
|
76
|
+
}
|
|
77
|
+
for (const [k, v] of Object.entries(update.langEdits)) {
|
|
78
|
+
base.langEdits[k] = (base.langEdits[k] ?? 0) + v;
|
|
79
|
+
}
|
|
80
|
+
for (const [k, v] of Object.entries(update.modelCost)) {
|
|
81
|
+
base.modelCost[k] = (base.modelCost[k] ?? 0) + v;
|
|
82
|
+
}
|
|
83
|
+
for (const [k, v] of Object.entries(update.modelCount)) {
|
|
84
|
+
base.modelCount[k] = (base.modelCount[k] ?? 0) + v;
|
|
85
|
+
}
|
|
86
|
+
for (const [k, v] of Object.entries(update.providerCost)) {
|
|
87
|
+
base.providerCost[k] = (base.providerCost[k] ?? 0) + v;
|
|
88
|
+
}
|
|
89
|
+
for (const [k, v] of Object.entries(update.providerCount)) {
|
|
90
|
+
base.providerCount[k] = (base.providerCount[k] ?? 0) + v;
|
|
91
|
+
}
|
|
92
|
+
for (const [k, v] of Object.entries(update.projectCost)) {
|
|
93
|
+
base.projectCost[k] = (base.projectCost[k] ?? 0) + v;
|
|
94
|
+
}
|
|
95
|
+
for (const [k, v] of Object.entries(update.projectSessions)) {
|
|
96
|
+
if (!base.projectSessions[k]) base.projectSessions[k] = new Set();
|
|
97
|
+
for (const id of v) base.projectSessions[k].add(id);
|
|
98
|
+
}
|
|
99
|
+
for (const [k, v] of Object.entries(update.toolCount)) {
|
|
100
|
+
base.toolCount[k] = (base.toolCount[k] ?? 0) + v;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// New fields
|
|
104
|
+
base.compactionCount += update.compactionCount;
|
|
105
|
+
base.compactedTokens += update.compactedTokens;
|
|
106
|
+
base.modelChanges += update.modelChanges;
|
|
107
|
+
for (const [k, v] of Object.entries(update.thinkingLevelCount)) {
|
|
108
|
+
base.thinkingLevelCount[k] = (base.thinkingLevelCount[k] ?? 0) + v;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
base.modelToProvider = new Map([
|
|
112
|
+
...(base.modelToProvider.size > 0 ? base.modelToProvider.entries() : []),
|
|
113
|
+
...(update.modelToProvider.size > 0 ? update.modelToProvider.entries() : []),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Session header ----
|
|
118
|
+
|
|
119
|
+
export function parseSessionHeader(entry: SessionHeader): DayAgg {
|
|
120
|
+
const day = emptyDay(dateFromISOString(entry.timestamp));
|
|
121
|
+
day.sessionIds.add(entry.id);
|
|
122
|
+
|
|
123
|
+
if (entry.cwd) {
|
|
124
|
+
const proj = projectNameFromCwd(entry.cwd);
|
|
125
|
+
sessionProjectMap.set(entry.id, proj);
|
|
126
|
+
day.projectCost[proj] = 0;
|
|
127
|
+
day.projectSessions[proj] = new Set([entry.id]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return day;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Message parsing ----
|
|
134
|
+
|
|
135
|
+
export function parseUserMessage(): DayAgg {
|
|
136
|
+
const day = emptyDay("");
|
|
137
|
+
day.userMsgs = 1;
|
|
138
|
+
return day;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseToolResultMessage(msg: ToolResultMessage): DayAgg {
|
|
142
|
+
const day = emptyDay("");
|
|
143
|
+
day.toolResults = 1;
|
|
144
|
+
if (msg.toolName) {
|
|
145
|
+
day.toolCount[sanitizeToolName(msg.toolName)] = 1;
|
|
146
|
+
}
|
|
147
|
+
return day;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseAssistantMessage(msg: AssistantMessage): DayAgg {
|
|
151
|
+
const day = emptyDay("");
|
|
152
|
+
day.asstMsgs = 1;
|
|
153
|
+
|
|
154
|
+
if (msg.usage) {
|
|
155
|
+
day.inTok = msg.usage.input;
|
|
156
|
+
day.outTok = msg.usage.output;
|
|
157
|
+
day.crTok = msg.usage.cacheRead;
|
|
158
|
+
day.cwTok = msg.usage.cacheWrite;
|
|
159
|
+
|
|
160
|
+
if (msg.usage.cost) {
|
|
161
|
+
day.cost = msg.usage.cost.total;
|
|
162
|
+
|
|
163
|
+
const activeProjects = new Set(sessionProjectMap.values());
|
|
164
|
+
for (const proj of activeProjects) {
|
|
165
|
+
day.projectCost[proj] = (day.projectCost[proj] ?? 0) + msg.usage.cost.total;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (msg.model) {
|
|
171
|
+
day.modelCost[msg.model] = msg.usage?.cost?.total || 0;
|
|
172
|
+
day.modelCount[msg.model] = 1;
|
|
173
|
+
if (msg.provider) {
|
|
174
|
+
day.modelToProvider.set(msg.model, msg.provider);
|
|
175
|
+
day.providerCost[msg.provider] = msg.usage?.cost?.total || 0;
|
|
176
|
+
day.providerCount[msg.provider] = 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (msg.content) {
|
|
181
|
+
for (const block of msg.content) {
|
|
182
|
+
if (block.type === "toolCall") {
|
|
183
|
+
const parsedArgs =
|
|
184
|
+
block.arguments !== undefined
|
|
185
|
+
? typeof block.arguments === "string"
|
|
186
|
+
? JSON.parse(block.arguments)
|
|
187
|
+
: block.arguments
|
|
188
|
+
: undefined;
|
|
189
|
+
const sanitized = sanitizeToolName(block.name);
|
|
190
|
+
day.toolCount[sanitized] = (day.toolCount[sanitized] ?? 0) + 1;
|
|
191
|
+
|
|
192
|
+
if (block.name === "edit" || block.name === "write") {
|
|
193
|
+
mergeDay(
|
|
194
|
+
day,
|
|
195
|
+
parseLanguageUsage(block.name, parsedArgs as Record<string, unknown> | undefined),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return day;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function countNewLines(s: string): number {
|
|
206
|
+
return (s.match(/\n/g) ?? []).length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function parseLanguageUsage(
|
|
210
|
+
toolName: string,
|
|
211
|
+
args: Record<string, unknown> | undefined,
|
|
212
|
+
): DayAgg {
|
|
213
|
+
const day = emptyDay("");
|
|
214
|
+
const path = args?.path as string | undefined;
|
|
215
|
+
if (!path) return day;
|
|
216
|
+
|
|
217
|
+
const lang = langFromPath(path);
|
|
218
|
+
|
|
219
|
+
if (toolName === "edit") {
|
|
220
|
+
const edits = args?.edits as Array<{ newText?: string; oldText?: string }> | undefined;
|
|
221
|
+
if (Array.isArray(edits)) {
|
|
222
|
+
let totalNewLines = 0;
|
|
223
|
+
for (const edit of edits) {
|
|
224
|
+
totalNewLines += countNewLines(edit.newText ?? "") + countNewLines(edit.oldText ?? "") + 1;
|
|
225
|
+
day.langEdits[lang] = (day.langEdits[lang] ?? 0) + 1;
|
|
226
|
+
}
|
|
227
|
+
day.langLines[lang] = (day.langLines[lang] ?? 0) + totalNewLines;
|
|
228
|
+
} else {
|
|
229
|
+
day.langLines[lang] = (day.langLines[lang] ?? 0) + 1;
|
|
230
|
+
day.langEdits[lang] = (day.langEdits[lang] ?? 0) + 1;
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
const contentStr = args?.content as string | undefined;
|
|
234
|
+
if (contentStr) {
|
|
235
|
+
// +1 because no new line is still a line
|
|
236
|
+
day.langLines[lang] = (day.langLines[lang] ?? 0) + countNewLines(contentStr);
|
|
237
|
+
}
|
|
238
|
+
day.langLines[lang] = (day.langLines[lang] ?? 0) + 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return day;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseAgentMessage(msg: AgentMessage): DayAgg {
|
|
245
|
+
switch (msg.role) {
|
|
246
|
+
case "user":
|
|
247
|
+
return parseUserMessage();
|
|
248
|
+
case "toolResult":
|
|
249
|
+
return parseToolResultMessage(msg as ToolResultMessage);
|
|
250
|
+
case "assistant":
|
|
251
|
+
return parseAssistantMessage(msg as AssistantMessage);
|
|
252
|
+
// skip non-cost-relevant message types
|
|
253
|
+
case "bashExecution":
|
|
254
|
+
case "custom":
|
|
255
|
+
case "branchSummary":
|
|
256
|
+
case "compactionSummary":
|
|
257
|
+
default:
|
|
258
|
+
return emptyDay("");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- New entry types ----
|
|
263
|
+
|
|
264
|
+
export function parseModelChangeEntry(entry: ModelChangeEntry): DayAgg {
|
|
265
|
+
const day = emptyDay(dateFromISOString(entry.timestamp));
|
|
266
|
+
day.modelChanges = 1;
|
|
267
|
+
return day;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function parseThinkingLevelChangeEntry(entry: ThinkingLevelChangeEntry): DayAgg {
|
|
271
|
+
const day = emptyDay(dateFromISOString(entry.timestamp));
|
|
272
|
+
day.thinkingLevelCount[entry.thinkingLevel] = 1;
|
|
273
|
+
return day;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function parseCompactionEntry(entry: CompactionEntry): DayAgg {
|
|
277
|
+
const day = emptyDay(dateFromISOString(entry.timestamp));
|
|
278
|
+
day.compactionCount = 1;
|
|
279
|
+
day.compactedTokens = entry.tokensBefore;
|
|
280
|
+
return day;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- Top-level dispatch ----
|
|
284
|
+
|
|
285
|
+
export function parseSessionLogEntry(entry: FileEntry): DayAgg | null {
|
|
286
|
+
// Runtime resilience: JSONL files may contain corrupt data despite typing
|
|
287
|
+
if (!entry || typeof entry !== "object") return null;
|
|
288
|
+
|
|
289
|
+
switch (entry.type) {
|
|
290
|
+
case "session":
|
|
291
|
+
return parseSessionHeader(entry as SessionHeader);
|
|
292
|
+
case "message": {
|
|
293
|
+
const msgEntry = entry as SessionMessageEntry;
|
|
294
|
+
const hour = new Date(msgEntry.timestamp).getHours();
|
|
295
|
+
const day = emptyDay(dateFromISOString(msgEntry.timestamp));
|
|
296
|
+
mergeDay(day, parseAgentMessage(msgEntry.message));
|
|
297
|
+
if (day.cost > 0) {
|
|
298
|
+
day.hourCost[hour] = (day.hourCost[hour] ?? 0) + day.cost;
|
|
299
|
+
}
|
|
300
|
+
return day;
|
|
301
|
+
}
|
|
302
|
+
case "model_change":
|
|
303
|
+
return parseModelChangeEntry(entry as ModelChangeEntry);
|
|
304
|
+
case "thinking_level_change":
|
|
305
|
+
return parseThinkingLevelChangeEntry(entry as ThinkingLevelChangeEntry);
|
|
306
|
+
case "compaction":
|
|
307
|
+
return parseCompactionEntry(entry as CompactionEntry);
|
|
308
|
+
// Silently skip entry types with no cost-relevant data
|
|
309
|
+
case "branch_summary":
|
|
310
|
+
case "custom":
|
|
311
|
+
case "custom_message":
|
|
312
|
+
case "label":
|
|
313
|
+
case "session_info":
|
|
314
|
+
default:
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---- Parse a full JSONL file ----
|
|
320
|
+
|
|
321
|
+
export function parseFile(
|
|
322
|
+
filePath: string,
|
|
323
|
+
onWarning?: (count: number) => void,
|
|
324
|
+
): Map<string, DayAgg> {
|
|
325
|
+
// Each JSONL file represents one session; reset global session→project
|
|
326
|
+
// tracking so costs from previous files don't leak across projects.
|
|
327
|
+
sessionProjectMap.clear();
|
|
328
|
+
|
|
329
|
+
const map = new Map<string, DayAgg>();
|
|
330
|
+
|
|
331
|
+
let content: string;
|
|
332
|
+
try {
|
|
333
|
+
content = readFileSync(filePath, "utf-8");
|
|
334
|
+
} catch {
|
|
335
|
+
return map;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const lines = content.split("\n");
|
|
339
|
+
let corruptCount = 0;
|
|
340
|
+
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
const trimmed = line.trim();
|
|
343
|
+
if (!trimmed) continue;
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const entry = JSON.parse(trimmed) as FileEntry;
|
|
347
|
+
const result = parseSessionLogEntry(entry);
|
|
348
|
+
if (result) {
|
|
349
|
+
const existing = map.get(result.date);
|
|
350
|
+
if (existing) {
|
|
351
|
+
mergeDay(existing, result);
|
|
352
|
+
} else {
|
|
353
|
+
map.set(result.date, result);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
corruptCount++;
|
|
358
|
+
if (onWarning) onWarning(corruptCount);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return map;
|
|
363
|
+
}
|