ai-wrapped 0.0.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/.0spec/config.toml +50 -0
- package/.0spec/flows/ai-stats-build.toml +291 -0
- package/.0spec/flows/ai-stats-fix.toml +285 -0
- package/.0spec/flows/ai-stats.toml +400 -0
- package/.github/workflows/publish.yml +28 -0
- package/CLAUDE.md +111 -0
- package/README.md +64 -0
- package/bun.lock +635 -0
- package/electrobun.config.ts +25 -0
- package/package.json +36 -0
- package/public/tray-icon.png +0 -0
- package/src/bun/aggregator.test.ts +49 -0
- package/src/bun/aggregator.ts +130 -0
- package/src/bun/discovery/claude.ts +18 -0
- package/src/bun/discovery/codex.ts +20 -0
- package/src/bun/discovery/copilot.ts +13 -0
- package/src/bun/discovery/droid.ts +13 -0
- package/src/bun/discovery/gemini.ts +13 -0
- package/src/bun/discovery/index.ts +28 -0
- package/src/bun/discovery/opencode.ts +13 -0
- package/src/bun/discovery/types.ts +13 -0
- package/src/bun/discovery/utils.ts +48 -0
- package/src/bun/index.ts +722 -0
- package/src/bun/normalizer.test.ts +101 -0
- package/src/bun/normalizer.ts +454 -0
- package/src/bun/parsers/claude.ts +234 -0
- package/src/bun/parsers/codex.test.ts +180 -0
- package/src/bun/parsers/codex.ts +435 -0
- package/src/bun/parsers/copilot.ts +4 -0
- package/src/bun/parsers/droid.ts +4 -0
- package/src/bun/parsers/gemini.ts +4 -0
- package/src/bun/parsers/generic.test.ts +97 -0
- package/src/bun/parsers/generic.ts +260 -0
- package/src/bun/parsers/index.ts +37 -0
- package/src/bun/parsers/opencode.ts +4 -0
- package/src/bun/parsers/types.ts +23 -0
- package/src/bun/pricing.ts +52 -0
- package/src/bun/scan.ts +77 -0
- package/src/bun/session-schema.ts +1 -0
- package/src/bun/store.ts +283 -0
- package/src/mainview/App.tsx +42 -0
- package/src/mainview/components/AgentBadge.tsx +17 -0
- package/src/mainview/components/Dashboard.tsx +229 -0
- package/src/mainview/components/DashboardCharts.tsx +499 -0
- package/src/mainview/components/EmptyState.tsx +17 -0
- package/src/mainview/components/Sidebar.tsx +30 -0
- package/src/mainview/components/StatsCards.tsx +118 -0
- package/src/mainview/hooks/useDashboardData.ts +315 -0
- package/src/mainview/hooks/useRPC.ts +29 -0
- package/src/mainview/index.css +195 -0
- package/src/mainview/index.html +12 -0
- package/src/mainview/index.ts +12 -0
- package/src/mainview/lib/constants.ts +32 -0
- package/src/mainview/lib/formatters.ts +82 -0
- package/src/shared/constants.ts +1 -0
- package/src/shared/schema.ts +71 -0
- package/src/shared/session-types.ts +61 -0
- package/src/shared/types.ts +59 -0
- package/src/types/electrobun-bun.d.ts +117 -0
- package/src/types/electrobun-root.d.ts +3 -0
- package/src/types/electrobun-view.d.ts +38 -0
- package/tsconfig.json +18 -0
- package/tsconfig.typecheck.json +11 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { FileCandidate } from "../discovery";
|
|
3
|
+
import { extractText, normalizeTimestamp, normalizeTokenUsage, resolveEventKind } from "../normalizer";
|
|
4
|
+
import type { SessionEvent } from "../session-schema";
|
|
5
|
+
import type { RawParsedSession, SessionParser } from "./types";
|
|
6
|
+
|
|
7
|
+
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
9
|
+
return value as Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getString = (value: unknown): string | null => {
|
|
13
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const parseJsonl = (content: string): Array<Record<string, unknown>> => {
|
|
17
|
+
const records: Array<Record<string, unknown>> = [];
|
|
18
|
+
|
|
19
|
+
for (const line of content.split(/\r?\n/)) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed) continue;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
25
|
+
const record = asRecord(parsed);
|
|
26
|
+
if (record) records.push(record);
|
|
27
|
+
} catch {
|
|
28
|
+
// Skip malformed lines and keep parsing remaining JSONL records.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return records;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const extractClaudeMessageText = (content: unknown): string | null => {
|
|
36
|
+
if (typeof content === "string") {
|
|
37
|
+
const trimmed = content.trim();
|
|
38
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!Array.isArray(content)) {
|
|
42
|
+
return extractText(content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const chunks: string[] = [];
|
|
46
|
+
for (const part of content) {
|
|
47
|
+
const block = asRecord(part);
|
|
48
|
+
if (!block) continue;
|
|
49
|
+
|
|
50
|
+
const type = getString(block.type);
|
|
51
|
+
if (type === "text") {
|
|
52
|
+
const text = getString(block.text);
|
|
53
|
+
if (text) chunks.push(text);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (type === "thinking") {
|
|
58
|
+
const thinking = getString(block.thinking);
|
|
59
|
+
if (thinking) chunks.push(thinking);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const joined = chunks.join("\n").trim();
|
|
64
|
+
return joined.length > 0 ? joined : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const buildBaseEvent = (
|
|
68
|
+
record: Record<string, unknown>,
|
|
69
|
+
sessionId: string,
|
|
70
|
+
index: number,
|
|
71
|
+
text: string | null,
|
|
72
|
+
kindOverride?: SessionEvent["kind"],
|
|
73
|
+
): SessionEvent => {
|
|
74
|
+
const message = asRecord(record.message);
|
|
75
|
+
const rawType = getString(record.type);
|
|
76
|
+
const role = getString(message?.role ?? record.role);
|
|
77
|
+
const usage = asRecord(message?.usage);
|
|
78
|
+
|
|
79
|
+
const tokens = usage
|
|
80
|
+
? normalizeTokenUsage({
|
|
81
|
+
input_tokens: usage.input_tokens,
|
|
82
|
+
output_tokens: usage.output_tokens,
|
|
83
|
+
cache_read_input_tokens: usage.cache_read_input_tokens,
|
|
84
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
|
85
|
+
})
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: getString(record.uuid) ?? getString(message?.id) ?? `${sessionId}:claude:${index}`,
|
|
90
|
+
sessionId,
|
|
91
|
+
kind: kindOverride ?? resolveEventKind(rawType, role),
|
|
92
|
+
timestamp: normalizeTimestamp(record.timestamp),
|
|
93
|
+
role,
|
|
94
|
+
text,
|
|
95
|
+
toolName: null,
|
|
96
|
+
toolInput: null,
|
|
97
|
+
toolOutput: null,
|
|
98
|
+
model: getString(message?.model),
|
|
99
|
+
parentId: getString(record.parentUuid),
|
|
100
|
+
messageId: getString(message?.id),
|
|
101
|
+
isDelta: false,
|
|
102
|
+
tokens,
|
|
103
|
+
costUsd: null,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const buildClaudeEvents = (record: Record<string, unknown>, sessionId: string, index: number): SessionEvent[] => {
|
|
108
|
+
const events: SessionEvent[] = [];
|
|
109
|
+
const message = asRecord(record.message);
|
|
110
|
+
const content = message?.content;
|
|
111
|
+
const rawType = getString(record.type);
|
|
112
|
+
|
|
113
|
+
let baseText: string | null = null;
|
|
114
|
+
if (rawType === "user" || rawType === "assistant") {
|
|
115
|
+
baseText = extractClaudeMessageText(content);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!baseText) {
|
|
119
|
+
baseText = extractText(content ?? message ?? record.summary ?? record.data);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const baseEvent = buildBaseEvent(record, sessionId, index, baseText);
|
|
123
|
+
events.push(baseEvent);
|
|
124
|
+
|
|
125
|
+
if (!Array.isArray(content)) {
|
|
126
|
+
return events;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
content.forEach((part, partIndex) => {
|
|
130
|
+
const block = asRecord(part);
|
|
131
|
+
if (!block) return;
|
|
132
|
+
|
|
133
|
+
const partType = getString(block.type);
|
|
134
|
+
if (partType === "tool_use") {
|
|
135
|
+
events.push({
|
|
136
|
+
...baseEvent,
|
|
137
|
+
id: `${baseEvent.id}:tool_use:${partIndex}`,
|
|
138
|
+
kind: "tool_call",
|
|
139
|
+
text: null,
|
|
140
|
+
toolName: getString(block.name),
|
|
141
|
+
toolInput: block.input ? JSON.stringify(block.input) : null,
|
|
142
|
+
toolOutput: null,
|
|
143
|
+
parentId: baseEvent.id,
|
|
144
|
+
messageId: getString(block.id) ?? baseEvent.messageId,
|
|
145
|
+
tokens: null,
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (partType === "tool_result") {
|
|
151
|
+
events.push({
|
|
152
|
+
...baseEvent,
|
|
153
|
+
id: `${baseEvent.id}:tool_result:${partIndex}`,
|
|
154
|
+
kind: "tool_result",
|
|
155
|
+
text: null,
|
|
156
|
+
toolName: null,
|
|
157
|
+
toolInput: null,
|
|
158
|
+
toolOutput: extractText(block.content),
|
|
159
|
+
parentId: getString(block.tool_use_id) ?? baseEvent.parentId,
|
|
160
|
+
messageId: getString(block.tool_use_id) ?? baseEvent.messageId,
|
|
161
|
+
tokens: null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return events;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const claudeParser: SessionParser = {
|
|
170
|
+
source: "claude",
|
|
171
|
+
async parse(candidate: FileCandidate): Promise<RawParsedSession | null> {
|
|
172
|
+
try {
|
|
173
|
+
const content = await readFile(candidate.path, "utf8");
|
|
174
|
+
const records = parseJsonl(content);
|
|
175
|
+
if (records.length === 0) return null;
|
|
176
|
+
|
|
177
|
+
const first = records[0] ?? {};
|
|
178
|
+
const firstMessage = asRecord(first.message);
|
|
179
|
+
|
|
180
|
+
const sessionId =
|
|
181
|
+
getString(first.sessionId) ??
|
|
182
|
+
records.map((record) => getString(record.sessionId)).find((value): value is string => Boolean(value)) ??
|
|
183
|
+
candidate.path.split("/").pop()?.replace(/\.jsonl$/, "") ??
|
|
184
|
+
`${Date.now()}`;
|
|
185
|
+
|
|
186
|
+
const metadata = {
|
|
187
|
+
cwd:
|
|
188
|
+
getString(first.cwd) ??
|
|
189
|
+
records.map((record) => getString(record.cwd)).find((value): value is string => Boolean(value)) ??
|
|
190
|
+
null,
|
|
191
|
+
gitBranch:
|
|
192
|
+
getString(first.gitBranch) ??
|
|
193
|
+
records.map((record) => getString(record.gitBranch)).find((value): value is string => Boolean(value)) ??
|
|
194
|
+
null,
|
|
195
|
+
model:
|
|
196
|
+
getString(firstMessage?.model) ??
|
|
197
|
+
records
|
|
198
|
+
.map((record) => getString(asRecord(record.message)?.model))
|
|
199
|
+
.find((value): value is string => Boolean(value)) ??
|
|
200
|
+
null,
|
|
201
|
+
cliVersion:
|
|
202
|
+
getString(first.version) ??
|
|
203
|
+
records.map((record) => getString(record.version)).find((value): value is string => Boolean(value)) ??
|
|
204
|
+
null,
|
|
205
|
+
title:
|
|
206
|
+
records
|
|
207
|
+
.map((record) => (getString(record.type) === "summary" ? getString(record.summary) : null))
|
|
208
|
+
.find((value): value is string => Boolean(value)) ??
|
|
209
|
+
null,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const events: SessionEvent[] = [];
|
|
213
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
214
|
+
const record = records[index] ?? {};
|
|
215
|
+
events.push(...buildClaudeEvents(record, sessionId, index));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (events.length === 0) return null;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
sessionId,
|
|
222
|
+
source: "claude",
|
|
223
|
+
filePath: candidate.path,
|
|
224
|
+
fileSizeBytes: candidate.size,
|
|
225
|
+
metadata,
|
|
226
|
+
events,
|
|
227
|
+
};
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const parseClaude = claudeParser.parse;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { normalizeSession } from "../normalizer";
|
|
6
|
+
import { codexParser } from "./codex";
|
|
7
|
+
|
|
8
|
+
describe("codexParser", () => {
|
|
9
|
+
test("generates unique event IDs when tool call and output share call_id", async () => {
|
|
10
|
+
const fixtureDir = mkdtempSync(join(tmpdir(), "ai-stats-codex-"));
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const filePath = join(fixtureDir, "rollout-2026-02-03T11-38-55-test-session.jsonl");
|
|
14
|
+
const content = [
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
type: "session_meta",
|
|
17
|
+
timestamp: "2026-02-03T11:38:55Z",
|
|
18
|
+
payload: {
|
|
19
|
+
id: "session-1",
|
|
20
|
+
cwd: "/tmp/project",
|
|
21
|
+
model_provider: "gpt-5",
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
type: "response_item",
|
|
26
|
+
timestamp: "2026-02-03T11:39:00Z",
|
|
27
|
+
payload: {
|
|
28
|
+
type: "function_call",
|
|
29
|
+
call_id: "call_shared",
|
|
30
|
+
name: "search_docs",
|
|
31
|
+
arguments: { q: "duplicate ids" },
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
type: "response_item",
|
|
36
|
+
timestamp: "2026-02-03T11:39:01Z",
|
|
37
|
+
payload: {
|
|
38
|
+
type: "function_call_output",
|
|
39
|
+
call_id: "call_shared",
|
|
40
|
+
output: "ok",
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
writeFileSync(filePath, content, "utf8");
|
|
46
|
+
const fileStat = statSync(filePath);
|
|
47
|
+
|
|
48
|
+
const parsed = await codexParser.parse({
|
|
49
|
+
path: filePath,
|
|
50
|
+
source: "codex",
|
|
51
|
+
mtime: fileStat.mtimeMs,
|
|
52
|
+
size: fileStat.size,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(parsed).not.toBeNull();
|
|
56
|
+
if (!parsed) return;
|
|
57
|
+
|
|
58
|
+
const ids = parsed.events.map((event) => event.id);
|
|
59
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
60
|
+
|
|
61
|
+
const toolCall = parsed.events.find((event) => event.kind === "tool_call");
|
|
62
|
+
const toolResult = parsed.events.find((event) => event.kind === "tool_result");
|
|
63
|
+
|
|
64
|
+
expect(toolCall).toBeDefined();
|
|
65
|
+
expect(toolResult).toBeDefined();
|
|
66
|
+
expect(toolCall?.messageId).toBe("call_shared");
|
|
67
|
+
expect(toolResult?.messageId).toBe("call_shared");
|
|
68
|
+
expect(toolCall?.id).not.toBe(toolResult?.id);
|
|
69
|
+
expect(toolResult?.parentId).toBe("call_shared");
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("counts token_count events once and ignores duplicate cumulative snapshots", async () => {
|
|
76
|
+
const fixtureDir = mkdtempSync(join(tmpdir(), "ai-stats-codex-"));
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const filePath = join(fixtureDir, "rollout-2026-02-03T11-38-55-token-count.jsonl");
|
|
80
|
+
const content = [
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
type: "session_meta",
|
|
83
|
+
timestamp: "2026-02-03T11:38:55Z",
|
|
84
|
+
payload: {
|
|
85
|
+
id: "session-token-count",
|
|
86
|
+
cwd: "/tmp/project",
|
|
87
|
+
model_provider: "openai",
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
type: "event_msg",
|
|
92
|
+
timestamp: "2026-02-03T11:39:00Z",
|
|
93
|
+
payload: {
|
|
94
|
+
type: "token_count",
|
|
95
|
+
info: {
|
|
96
|
+
total_token_usage: {
|
|
97
|
+
input_tokens: 100,
|
|
98
|
+
cached_input_tokens: 50,
|
|
99
|
+
output_tokens: 20,
|
|
100
|
+
reasoning_output_tokens: 10,
|
|
101
|
+
},
|
|
102
|
+
last_token_usage: {
|
|
103
|
+
input_tokens: 100,
|
|
104
|
+
cached_input_tokens: 50,
|
|
105
|
+
output_tokens: 20,
|
|
106
|
+
reasoning_output_tokens: 10,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
type: "event_msg",
|
|
113
|
+
timestamp: "2026-02-03T11:39:01Z",
|
|
114
|
+
payload: {
|
|
115
|
+
type: "token_count",
|
|
116
|
+
info: {
|
|
117
|
+
total_token_usage: {
|
|
118
|
+
input_tokens: 100,
|
|
119
|
+
cached_input_tokens: 50,
|
|
120
|
+
output_tokens: 20,
|
|
121
|
+
reasoning_output_tokens: 10,
|
|
122
|
+
},
|
|
123
|
+
last_token_usage: {
|
|
124
|
+
input_tokens: 100,
|
|
125
|
+
cached_input_tokens: 50,
|
|
126
|
+
output_tokens: 20,
|
|
127
|
+
reasoning_output_tokens: 10,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
type: "event_msg",
|
|
134
|
+
timestamp: "2026-02-03T11:39:02Z",
|
|
135
|
+
payload: {
|
|
136
|
+
type: "token_count",
|
|
137
|
+
info: {
|
|
138
|
+
total_token_usage: {
|
|
139
|
+
input_tokens: 170,
|
|
140
|
+
cached_input_tokens: 80,
|
|
141
|
+
output_tokens: 35,
|
|
142
|
+
reasoning_output_tokens: 15,
|
|
143
|
+
},
|
|
144
|
+
last_token_usage: {
|
|
145
|
+
input_tokens: 70,
|
|
146
|
+
cached_input_tokens: 30,
|
|
147
|
+
output_tokens: 15,
|
|
148
|
+
reasoning_output_tokens: 5,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
].join("\n");
|
|
154
|
+
|
|
155
|
+
writeFileSync(filePath, content, "utf8");
|
|
156
|
+
const fileStat = statSync(filePath);
|
|
157
|
+
|
|
158
|
+
const parsed = await codexParser.parse({
|
|
159
|
+
path: filePath,
|
|
160
|
+
source: "codex",
|
|
161
|
+
mtime: fileStat.mtimeMs,
|
|
162
|
+
size: fileStat.size,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(parsed).not.toBeNull();
|
|
166
|
+
if (!parsed) return;
|
|
167
|
+
|
|
168
|
+
const normalized = normalizeSession(parsed);
|
|
169
|
+
expect(normalized.session.totalTokens).toEqual({
|
|
170
|
+
inputTokens: 90,
|
|
171
|
+
outputTokens: 20,
|
|
172
|
+
cacheReadTokens: 80,
|
|
173
|
+
cacheWriteTokens: 0,
|
|
174
|
+
reasoningTokens: 15,
|
|
175
|
+
});
|
|
176
|
+
} finally {
|
|
177
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|