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.
Files changed (64) hide show
  1. package/.0spec/config.toml +50 -0
  2. package/.0spec/flows/ai-stats-build.toml +291 -0
  3. package/.0spec/flows/ai-stats-fix.toml +285 -0
  4. package/.0spec/flows/ai-stats.toml +400 -0
  5. package/.github/workflows/publish.yml +28 -0
  6. package/CLAUDE.md +111 -0
  7. package/README.md +64 -0
  8. package/bun.lock +635 -0
  9. package/electrobun.config.ts +25 -0
  10. package/package.json +36 -0
  11. package/public/tray-icon.png +0 -0
  12. package/src/bun/aggregator.test.ts +49 -0
  13. package/src/bun/aggregator.ts +130 -0
  14. package/src/bun/discovery/claude.ts +18 -0
  15. package/src/bun/discovery/codex.ts +20 -0
  16. package/src/bun/discovery/copilot.ts +13 -0
  17. package/src/bun/discovery/droid.ts +13 -0
  18. package/src/bun/discovery/gemini.ts +13 -0
  19. package/src/bun/discovery/index.ts +28 -0
  20. package/src/bun/discovery/opencode.ts +13 -0
  21. package/src/bun/discovery/types.ts +13 -0
  22. package/src/bun/discovery/utils.ts +48 -0
  23. package/src/bun/index.ts +722 -0
  24. package/src/bun/normalizer.test.ts +101 -0
  25. package/src/bun/normalizer.ts +454 -0
  26. package/src/bun/parsers/claude.ts +234 -0
  27. package/src/bun/parsers/codex.test.ts +180 -0
  28. package/src/bun/parsers/codex.ts +435 -0
  29. package/src/bun/parsers/copilot.ts +4 -0
  30. package/src/bun/parsers/droid.ts +4 -0
  31. package/src/bun/parsers/gemini.ts +4 -0
  32. package/src/bun/parsers/generic.test.ts +97 -0
  33. package/src/bun/parsers/generic.ts +260 -0
  34. package/src/bun/parsers/index.ts +37 -0
  35. package/src/bun/parsers/opencode.ts +4 -0
  36. package/src/bun/parsers/types.ts +23 -0
  37. package/src/bun/pricing.ts +52 -0
  38. package/src/bun/scan.ts +77 -0
  39. package/src/bun/session-schema.ts +1 -0
  40. package/src/bun/store.ts +283 -0
  41. package/src/mainview/App.tsx +42 -0
  42. package/src/mainview/components/AgentBadge.tsx +17 -0
  43. package/src/mainview/components/Dashboard.tsx +229 -0
  44. package/src/mainview/components/DashboardCharts.tsx +499 -0
  45. package/src/mainview/components/EmptyState.tsx +17 -0
  46. package/src/mainview/components/Sidebar.tsx +30 -0
  47. package/src/mainview/components/StatsCards.tsx +118 -0
  48. package/src/mainview/hooks/useDashboardData.ts +315 -0
  49. package/src/mainview/hooks/useRPC.ts +29 -0
  50. package/src/mainview/index.css +195 -0
  51. package/src/mainview/index.html +12 -0
  52. package/src/mainview/index.ts +12 -0
  53. package/src/mainview/lib/constants.ts +32 -0
  54. package/src/mainview/lib/formatters.ts +82 -0
  55. package/src/shared/constants.ts +1 -0
  56. package/src/shared/schema.ts +71 -0
  57. package/src/shared/session-types.ts +61 -0
  58. package/src/shared/types.ts +59 -0
  59. package/src/types/electrobun-bun.d.ts +117 -0
  60. package/src/types/electrobun-root.d.ts +3 -0
  61. package/src/types/electrobun-view.d.ts +38 -0
  62. package/tsconfig.json +18 -0
  63. package/tsconfig.typecheck.json +11 -0
  64. package/vite.config.ts +23 -0
@@ -0,0 +1,260 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import type { SessionSource } from "../../shared/schema";
4
+ import type { FileCandidate } from "../discovery";
5
+ import type { SessionEvent } from "../session-schema";
6
+ import { extractText, normalizeTimestamp, normalizeTokenUsage, resolveEventKind } from "../normalizer";
7
+ import type { RawParsedSession } from "./types";
8
+
9
+ const asRecord = (value: unknown): Record<string, unknown> | null => {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
11
+ return value as Record<string, unknown>;
12
+ };
13
+
14
+ const parseJsonlRecords = (content: string): Array<Record<string, unknown>> => {
15
+ const records: Array<Record<string, unknown>> = [];
16
+ for (const line of content.split(/\r?\n/)) {
17
+ const trimmed = line.trim();
18
+ if (!trimmed) continue;
19
+ try {
20
+ const parsed = JSON.parse(trimmed) as unknown;
21
+ const record = asRecord(parsed);
22
+ if (record) records.push(record);
23
+ } catch {
24
+ // Skip malformed lines.
25
+ }
26
+ }
27
+ return records;
28
+ };
29
+
30
+ const parseJsonOrJsonl = (path: string, content: string): Array<Record<string, unknown>> => {
31
+ const trimmed = content.trim();
32
+ if (trimmed.length === 0) return [];
33
+
34
+ if (path.endsWith(".jsonl")) return parseJsonlRecords(content);
35
+
36
+ try {
37
+ const parsed = JSON.parse(trimmed) as unknown;
38
+
39
+ if (Array.isArray(parsed)) {
40
+ return parsed.map(asRecord).filter((record): record is Record<string, unknown> => Boolean(record));
41
+ }
42
+
43
+ const record = asRecord(parsed);
44
+ if (!record) return [];
45
+
46
+ // Gemini-style single JSON session payload.
47
+ if (Array.isArray(record.messages)) {
48
+ const header = {
49
+ _kind: "json_header",
50
+ sessionId: record.sessionId,
51
+ cwd: record.cwd,
52
+ startTime: record.startTime,
53
+ lastUpdated: record.lastUpdated,
54
+ } satisfies Record<string, unknown>;
55
+
56
+ const messageRecords = record.messages
57
+ .map(asRecord)
58
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry))
59
+ .map((entry) => ({
60
+ ...entry,
61
+ _kind: "json_message",
62
+ }));
63
+
64
+ return [header, ...messageRecords];
65
+ }
66
+
67
+ return [record];
68
+ } catch {
69
+ // If `.json` fails as a single JSON object, try JSONL fallback.
70
+ return parseJsonlRecords(content);
71
+ }
72
+ };
73
+
74
+ const deriveSessionId = (candidate: FileCandidate, records: Array<Record<string, unknown>>): string => {
75
+ for (const record of records) {
76
+ const direct = [record.sessionId, record.id, record.uuid];
77
+ for (const value of direct) {
78
+ if (typeof value === "string" && value.trim().length > 0) {
79
+ return value;
80
+ }
81
+ }
82
+
83
+ const payload = asRecord(record.payload);
84
+ const payloadId = payload?.id;
85
+ if (typeof payloadId === "string" && payloadId.trim().length > 0) {
86
+ return payloadId;
87
+ }
88
+ }
89
+
90
+ return basename(candidate.path).replace(/\.[^.]+$/, "");
91
+ };
92
+
93
+ const getFirstString = (...values: unknown[]): string | null => {
94
+ for (const value of values) {
95
+ if (typeof value === "string" && value.trim().length > 0) {
96
+ return value;
97
+ }
98
+ }
99
+ return null;
100
+ };
101
+
102
+ const getNested = (value: unknown, path: string[]): unknown => {
103
+ let current: unknown = value;
104
+ for (const part of path) {
105
+ if (!current || typeof current !== "object" || !(part in (current as Record<string, unknown>))) {
106
+ return undefined;
107
+ }
108
+ current = (current as Record<string, unknown>)[part];
109
+ }
110
+ return current;
111
+ };
112
+
113
+ const isDeltaEvent = (type: unknown, payload: Record<string, unknown> | null): boolean => {
114
+ if (typeof type === "string" && type.toLowerCase().includes("delta")) return true;
115
+ if (!payload) return false;
116
+
117
+ if (typeof payload.isDelta === "boolean") return payload.isDelta;
118
+ if (payload.delta !== undefined || payload.content_delta !== undefined) return true;
119
+
120
+ const content = payload.content;
121
+ if (Array.isArray(content)) {
122
+ return content.some((part) => {
123
+ const entry = asRecord(part);
124
+ if (!entry) return false;
125
+ const entryType = entry.type;
126
+ return typeof entryType === "string" && entryType.toLowerCase().includes("delta");
127
+ });
128
+ }
129
+
130
+ return false;
131
+ };
132
+
133
+ const buildEventFromRecord = (
134
+ record: Record<string, unknown>,
135
+ sessionId: string,
136
+ lineIndex: number,
137
+ modelFallback: string | null,
138
+ ): SessionEvent => {
139
+ const payload = asRecord(record.payload);
140
+ const message = asRecord(record.message);
141
+
142
+ const rawType = getFirstString(
143
+ record.type,
144
+ payload?.type,
145
+ message?.type,
146
+ (record._kind as string | undefined) ?? null,
147
+ );
148
+ const role = getFirstString(record.role, payload?.role, message?.role);
149
+
150
+ const text = extractText(
151
+ payload?.content ??
152
+ payload?.message ??
153
+ message?.content ??
154
+ record.content ??
155
+ record.text ??
156
+ record.summary ??
157
+ payload?.summary,
158
+ );
159
+
160
+ const messageId = getFirstString(
161
+ record.messageId,
162
+ record.message_id,
163
+ record.id,
164
+ payload?.id,
165
+ payload?.call_id,
166
+ message?.id,
167
+ );
168
+
169
+ const eventId =
170
+ getFirstString(record.uuid, record.id, message?.id, payload?.id, payload?.call_id, messageId) ??
171
+ `${sessionId}:generic:${lineIndex}`;
172
+
173
+ const toolInputValue = payload?.arguments ?? payload?.input ?? record.toolInput ?? message?.input;
174
+ const toolOutputValue = payload?.output ?? payload?.result ?? record.toolOutput ?? message?.output;
175
+ const tokensPayload =
176
+ payload?.tokens ??
177
+ payload?.usage ??
178
+ getNested(payload, ["info", "last_token_usage"]) ??
179
+ getNested(payload, ["info", "total_token_usage"]) ??
180
+ getNested(payload, ["completion", "usage"]) ??
181
+ message?.usage ??
182
+ message?.tokens ??
183
+ record.tokens ??
184
+ record.usage ??
185
+ getNested(record, ["completion", "usage"]) ??
186
+ getNested(record, ["data", "usage"]);
187
+ const tokens = normalizeTokenUsage(tokensPayload);
188
+
189
+ return {
190
+ id: eventId,
191
+ sessionId,
192
+ kind: resolveEventKind(rawType, role),
193
+ timestamp: normalizeTimestamp(record.timestamp ?? payload?.timestamp ?? message?.timestamp ?? record.time),
194
+ role,
195
+ text,
196
+ toolName: getFirstString(payload?.name, record.toolName, message?.name),
197
+ toolInput: toolInputValue ? JSON.stringify(toolInputValue) : null,
198
+ toolOutput: toolOutputValue ? extractText(toolOutputValue) : null,
199
+ model: getFirstString(record.model, payload?.model, message?.model, modelFallback),
200
+ parentId: getFirstString(record.parentId, record.parentUuid, payload?.parent_id, message?.parentId),
201
+ messageId,
202
+ isDelta: isDeltaEvent(rawType, payload),
203
+ tokens,
204
+ costUsd: null,
205
+ };
206
+ };
207
+
208
+ export const parseGeneric = async (
209
+ candidate: FileCandidate,
210
+ source: SessionSource = candidate.source,
211
+ ): Promise<RawParsedSession | null> => {
212
+ try {
213
+ const content = await readFile(candidate.path, "utf8");
214
+ const records = parseJsonOrJsonl(candidate.path, content);
215
+ if (records.length === 0) return null;
216
+
217
+ const sessionId = deriveSessionId(candidate, records);
218
+
219
+ let cwd: string | null = null;
220
+ let gitBranch: string | null = null;
221
+ let model: string | null = null;
222
+ let cliVersion: string | null = null;
223
+ let title: string | null = null;
224
+
225
+ const events: SessionEvent[] = [];
226
+
227
+ for (let index = 0; index < records.length; index += 1) {
228
+ const record = records[index] ?? {};
229
+ const payload = asRecord(record.payload);
230
+
231
+ cwd = cwd ?? getFirstString(record.cwd, payload?.cwd);
232
+ gitBranch = gitBranch ?? getFirstString(record.gitBranch, payload?.gitBranch, asRecord(payload?.git)?.branch);
233
+ model = model ?? getFirstString(record.model, payload?.model);
234
+ cliVersion = cliVersion ?? getFirstString(record.version, payload?.cli_version, payload?.copilotVersion);
235
+ title = title ?? getFirstString(record.title, record.summary);
236
+
237
+ const event = buildEventFromRecord(record, sessionId, index, model);
238
+ events.push(event);
239
+ }
240
+
241
+ if (events.length === 0) return null;
242
+
243
+ return {
244
+ sessionId,
245
+ source,
246
+ filePath: candidate.path,
247
+ fileSizeBytes: candidate.size,
248
+ metadata: {
249
+ cwd,
250
+ gitBranch,
251
+ model,
252
+ cliVersion,
253
+ title,
254
+ },
255
+ events,
256
+ };
257
+ } catch {
258
+ return null;
259
+ }
260
+ };
@@ -0,0 +1,37 @@
1
+ import type { SessionSource } from "../../shared/schema";
2
+ import type { FileCandidate } from "../discovery";
3
+ import { claudeParser } from "./claude";
4
+ import { codexParser } from "./codex";
5
+ import { parseCopilot } from "./copilot";
6
+ import { parseDroid } from "./droid";
7
+ import { parseGemini } from "./gemini";
8
+ import { parseGeneric } from "./generic";
9
+ import { parseOpencode } from "./opencode";
10
+ import type { RawParsedSession, SessionParser } from "./types";
11
+
12
+ const PARSERS: Record<SessionSource, SessionParser["parse"]> = {
13
+ claude: claudeParser.parse,
14
+ codex: codexParser.parse,
15
+ gemini: parseGemini,
16
+ opencode: parseOpencode,
17
+ droid: parseDroid,
18
+ copilot: parseCopilot,
19
+ };
20
+
21
+ export const parseFile = async (candidate: FileCandidate): Promise<RawParsedSession | null> => {
22
+ try {
23
+ const parser = PARSERS[candidate.source];
24
+ const parsed = await parser(candidate);
25
+ if (parsed) return parsed;
26
+ return await parseGeneric(candidate, candidate.source);
27
+ } catch (error) {
28
+ console.error(`[parse] Failed ${candidate.source} ${candidate.path}`, error);
29
+ try {
30
+ return await parseGeneric(candidate, candidate.source);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ };
36
+
37
+ export type { RawParsedSession } from "./types";
@@ -0,0 +1,4 @@
1
+ import type { FileCandidate } from "../discovery";
2
+ import { parseGeneric } from "./generic";
3
+
4
+ export const parseOpencode = async (candidate: FileCandidate) => parseGeneric(candidate, "opencode");
@@ -0,0 +1,23 @@
1
+ import type { SessionSource } from "../../shared/schema";
2
+ import type { SessionEvent } from "../session-schema";
3
+ import type { FileCandidate } from "../discovery";
4
+
5
+ export interface RawParsedSession {
6
+ sessionId: string;
7
+ source: SessionSource;
8
+ filePath: string;
9
+ fileSizeBytes: number;
10
+ metadata: {
11
+ cwd: string | null;
12
+ gitBranch: string | null;
13
+ model: string | null;
14
+ cliVersion: string | null;
15
+ title: string | null;
16
+ };
17
+ events: SessionEvent[];
18
+ }
19
+
20
+ export interface SessionParser {
21
+ source: SessionSource;
22
+ parse(candidate: FileCandidate): Promise<RawParsedSession | null>;
23
+ }
@@ -0,0 +1,52 @@
1
+ import type { TokenUsage } from "../shared/schema";
2
+
3
+ export interface ModelPricing {
4
+ inputPer1M: number;
5
+ outputPer1M: number;
6
+ cacheReadPer1M: number;
7
+ cacheWritePer1M: number;
8
+ }
9
+
10
+ export const PRICING: Record<string, ModelPricing> = {
11
+ "claude-opus-4-6": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
12
+ "claude-opus-4-5-20251101": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
13
+ "claude-sonnet-4-20250514": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
14
+ "claude-3-5-sonnet-20241022": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
15
+ "claude-3-5-haiku-20241022": { inputPer1M: 0.8, outputPer1M: 4, cacheReadPer1M: 0.08, cacheWritePer1M: 1 },
16
+
17
+ "gpt-5.2-codex": { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
18
+ "gpt-5.3-codex": { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
19
+ "gpt-5-codex": { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
20
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, cacheReadPer1M: 1.25, cacheWritePer1M: 0 },
21
+ o1: { inputPer1M: 15, outputPer1M: 60, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
22
+ o3: { inputPer1M: 10, outputPer1M: 40, cacheReadPer1M: 2.5, cacheWritePer1M: 0 },
23
+
24
+ "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.315, cacheWritePer1M: 4.5 },
25
+ "gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0.0375, cacheWritePer1M: 1 },
26
+ };
27
+
28
+ const findPricingByPrefix = (model: string): ModelPricing | null => {
29
+ const normalizedModel = model.toLowerCase();
30
+ const keys = Object.keys(PRICING).sort((a, b) => b.length - a.length);
31
+
32
+ for (const key of keys) {
33
+ if (normalizedModel === key || normalizedModel.startsWith(`${key}-`)) {
34
+ return PRICING[key] ?? null;
35
+ }
36
+ }
37
+
38
+ return null;
39
+ };
40
+
41
+ export const computeCost = (tokens: TokenUsage | null, model: string | null): number | null => {
42
+ if (!tokens || !model) return null;
43
+ const pricing = PRICING[model.toLowerCase()] ?? findPricingByPrefix(model);
44
+ if (!pricing) return null;
45
+
46
+ return (
47
+ (tokens.inputTokens * pricing.inputPer1M) / 1_000_000 +
48
+ (tokens.outputTokens * pricing.outputPer1M) / 1_000_000 +
49
+ (tokens.cacheReadTokens * pricing.cacheReadPer1M) / 1_000_000 +
50
+ (tokens.cacheWriteTokens * pricing.cacheWritePer1M) / 1_000_000
51
+ );
52
+ };
@@ -0,0 +1,77 @@
1
+ import type { SessionSource } from "../shared/schema";
2
+ import { aggregateSessionsByDate, mergeDailyAggregates } from "./aggregator";
3
+ import { discoverAll } from "./discovery";
4
+ import { normalizeSession } from "./normalizer";
5
+ import { parseFile } from "./parsers";
6
+ import type { Session } from "./session-schema";
7
+ import { readDailyStore, readScanState, writeDailyStore, writeScanState } from "./store";
8
+
9
+ export interface ScanOptions {
10
+ fullScan?: boolean;
11
+ sources?: SessionSource[];
12
+ }
13
+
14
+ export interface ScanResult {
15
+ scanned: number;
16
+ total: number;
17
+ errors: number;
18
+ }
19
+
20
+ export const runScan = async (options: ScanOptions = {}): Promise<ScanResult> => {
21
+ const candidates = await discoverAll(options.sources);
22
+ const scanState = await readScanState();
23
+
24
+ const changed = options.fullScan
25
+ ? candidates
26
+ : candidates.filter((candidate) => {
27
+ const state = scanState[candidate.path];
28
+ return !state || state.mtimeMs !== candidate.mtime || state.fileSize !== candidate.size;
29
+ });
30
+
31
+ let scanned = 0;
32
+ let errors = 0;
33
+ const sessions: Session[] = [];
34
+
35
+ for (const candidate of changed) {
36
+ const parsed = await parseFile(candidate);
37
+
38
+ if (!parsed) {
39
+ errors += 1;
40
+ scanState[candidate.path] = {
41
+ source: candidate.source,
42
+ fileSize: candidate.size,
43
+ mtimeMs: candidate.mtime,
44
+ parsedAt: new Date().toISOString(),
45
+ };
46
+ continue;
47
+ }
48
+
49
+ const { session } = normalizeSession(parsed);
50
+ sessions.push(session);
51
+
52
+ scanState[candidate.path] = {
53
+ source: candidate.source,
54
+ fileSize: candidate.size,
55
+ mtimeMs: candidate.mtime,
56
+ parsedAt: session.parsedAt,
57
+ };
58
+
59
+ scanned += 1;
60
+ }
61
+
62
+ await writeScanState(scanState);
63
+
64
+ if (options.fullScan) {
65
+ await writeDailyStore(aggregateSessionsByDate(sessions));
66
+ } else if (sessions.length > 0) {
67
+ const existingDaily = await readDailyStore();
68
+ const nextDaily = mergeDailyAggregates(existingDaily, aggregateSessionsByDate(sessions));
69
+ await writeDailyStore(nextDaily);
70
+ }
71
+
72
+ return {
73
+ scanned,
74
+ total: candidates.length,
75
+ errors,
76
+ };
77
+ };
@@ -0,0 +1 @@
1
+ export type { Session, SessionEvent, SessionEventKind } from "../shared/schema";