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,25 @@
1
+ import type { ElectrobunConfig } from "electrobun";
2
+
3
+ export default {
4
+ app: {
5
+ name: "AI Wrapped",
6
+ identifier: "com.aiwrapped.app",
7
+ version: "0.1.0",
8
+ },
9
+ runtime: {
10
+ exitOnLastWindowClosed: false,
11
+ },
12
+ build: {
13
+ bun: {
14
+ entrypoint: "src/bun/index.ts",
15
+ },
16
+ copy: {
17
+ "dist/index.html": "views/mainview/index.html",
18
+ "dist/assets": "views/mainview/assets",
19
+ "public/tray-icon.png": "views/mainview/tray-icon.png",
20
+ },
21
+ mac: { bundleCEF: false },
22
+ linux: { bundleCEF: true },
23
+ win: { bundleCEF: false },
24
+ },
25
+ } satisfies ElectrobunConfig;
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "ai-wrapped",
3
+ "version": "0.0.1",
4
+ "description": "Your year in AI — a Spotify Wrapped-style dashboard for AI coding agents",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gulivan/ai-wrapped.git"
9
+ },
10
+ "keywords": ["ai", "wrapped", "claude", "codex", "gemini", "copilot", "stats", "dashboard"],
11
+ "scripts": {
12
+ "dev": "bun run build && electrobun dev --console",
13
+ "dev:hmr": "bun run build && concurrently \"vite --port 5173\" \"electrobun dev --console\"",
14
+ "build": "vite build && electrobun build",
15
+ "build:prod": "vite build && electrobun build --env=stable",
16
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
17
+ "clean": "rm -rf dist build .electrobun"
18
+ },
19
+ "dependencies": {
20
+ "electrobun": "latest",
21
+ "react": "latest",
22
+ "react-dom": "latest",
23
+ "recharts": "latest"
24
+ },
25
+ "devDependencies": {
26
+ "@tailwindcss/vite": "latest",
27
+ "@types/bun": "latest",
28
+ "@types/react": "latest",
29
+ "@types/react-dom": "latest",
30
+ "@vitejs/plugin-react": "latest",
31
+ "concurrently": "latest",
32
+ "tailwindcss": "latest",
33
+ "typescript": "latest",
34
+ "vite": "latest"
35
+ }
36
+ }
Binary file
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { EMPTY_TOKEN_USAGE } from "../shared/schema";
3
+ import { aggregateSessionsByDate } from "./aggregator";
4
+ import type { Session } from "./session-schema";
5
+
6
+ const makeSession = (overrides: Partial<Session>): Session => ({
7
+ id: "session-1",
8
+ source: "codex",
9
+ filePath: "/tmp/session-1.jsonl",
10
+ fileSizeBytes: 100,
11
+ startTime: "2026-02-21T10:00:00.000Z",
12
+ endTime: "2026-02-21T10:01:00.000Z",
13
+ durationMs: 60_000,
14
+ title: "Test session",
15
+ model: "gpt-5",
16
+ cwd: "/tmp/ai-stats",
17
+ repoName: "ai-stats",
18
+ gitBranch: "main",
19
+ cliVersion: "1.0.0",
20
+ eventCount: 4,
21
+ messageCount: 2,
22
+ totalTokens: { ...EMPTY_TOKEN_USAGE },
23
+ totalCostUsd: 0.5,
24
+ toolCallCount: 1,
25
+ isHousekeeping: false,
26
+ parsedAt: "2026-02-21T10:02:00.000Z",
27
+ ...overrides,
28
+ });
29
+
30
+ describe("aggregateSessionsByDate", () => {
31
+ test("tracks per-repository totals in byRepo", () => {
32
+ const daily = aggregateSessionsByDate([
33
+ makeSession({ id: "s1", totalCostUsd: 1.25 }),
34
+ makeSession({ id: "s2", totalCostUsd: 0.75 }),
35
+ makeSession({ id: "s3", repoName: "other-repo", totalCostUsd: 2.0 }),
36
+ makeSession({ id: "s4", repoName: null, totalCostUsd: 3.0 }),
37
+ ]);
38
+
39
+ const entry = daily["2026-02-21"];
40
+ expect(entry).toBeDefined();
41
+ expect(Object.keys(entry?.byRepo ?? {})).toEqual(["ai-stats", "other-repo"]);
42
+
43
+ expect(entry?.byRepo["ai-stats"]?.sessions).toBe(2);
44
+ expect(entry?.byRepo["ai-stats"]?.costUsd).toBe(2);
45
+ expect(entry?.byRepo["other-repo"]?.sessions).toBe(1);
46
+ expect(entry?.byRepo["other-repo"]?.costUsd).toBe(2);
47
+ expect(entry?.totals.sessions).toBe(4);
48
+ });
49
+ });
@@ -0,0 +1,130 @@
1
+ import type { Session } from "./session-schema";
2
+ import {
3
+ createEmptyDayStats,
4
+ type DailyAggregateEntry,
5
+ type DailyStore,
6
+ type DayStats,
7
+ } from "./store";
8
+
9
+ const addStats = (target: DayStats, source: DayStats): void => {
10
+ target.sessions += source.sessions;
11
+ target.messages += source.messages;
12
+ target.toolCalls += source.toolCalls;
13
+ target.inputTokens += source.inputTokens;
14
+ target.outputTokens += source.outputTokens;
15
+ target.cacheReadTokens += source.cacheReadTokens;
16
+ target.cacheWriteTokens += source.cacheWriteTokens;
17
+ target.reasoningTokens += source.reasoningTokens;
18
+ target.costUsd += source.costUsd;
19
+ target.durationMs += source.durationMs;
20
+ };
21
+
22
+ const toDayKey = (session: Session): string => {
23
+ const timestamp = session.startTime ?? session.parsedAt;
24
+ if (typeof timestamp === "string" && timestamp.length >= 10) {
25
+ return timestamp.slice(0, 10);
26
+ }
27
+ return new Date().toISOString().slice(0, 10);
28
+ };
29
+
30
+ const toSessionStats = (session: Session): DayStats => ({
31
+ sessions: 1,
32
+ messages: session.messageCount,
33
+ toolCalls: session.toolCallCount,
34
+ inputTokens: session.totalTokens.inputTokens,
35
+ outputTokens: session.totalTokens.outputTokens,
36
+ cacheReadTokens: session.totalTokens.cacheReadTokens,
37
+ cacheWriteTokens: session.totalTokens.cacheWriteTokens,
38
+ reasoningTokens: session.totalTokens.reasoningTokens,
39
+ costUsd: session.totalCostUsd ?? 0,
40
+ durationMs: session.durationMs ?? 0,
41
+ });
42
+
43
+ const toRepoKey = (repoName: string | null): string | null => {
44
+ if (!repoName) return null;
45
+ const trimmed = repoName.trim();
46
+ return trimmed.length > 0 ? trimmed : null;
47
+ };
48
+
49
+ const ensureDateEntry = (daily: DailyStore, date: string): DailyAggregateEntry => {
50
+ const existing = daily[date];
51
+ if (existing) {
52
+ return existing;
53
+ }
54
+
55
+ const created: DailyAggregateEntry = {
56
+ bySource: {},
57
+ byModel: {},
58
+ byRepo: {},
59
+ totals: createEmptyDayStats(),
60
+ };
61
+ daily[date] = created;
62
+ return created;
63
+ };
64
+
65
+ const sortedEntries = <T>(entries: Record<string, T>): Record<string, T> => {
66
+ const keys = Object.keys(entries).sort((a, b) => a.localeCompare(b));
67
+ const sorted: Record<string, T> = {};
68
+ for (const key of keys) {
69
+ sorted[key] = entries[key] as T;
70
+ }
71
+ return sorted;
72
+ };
73
+
74
+ const sortDailyStore = (daily: DailyStore): DailyStore => {
75
+ const sortedDates = Object.keys(daily).sort((a, b) => a.localeCompare(b));
76
+ const output: DailyStore = {};
77
+
78
+ for (const date of sortedDates) {
79
+ const entry = daily[date] as DailyAggregateEntry;
80
+ output[date] = {
81
+ bySource: sortedEntries(entry.bySource),
82
+ byModel: sortedEntries(entry.byModel),
83
+ byRepo: sortedEntries(entry.byRepo),
84
+ totals: { ...entry.totals },
85
+ };
86
+ }
87
+
88
+ return output;
89
+ };
90
+
91
+ export const aggregateSessionsByDate = (sessions: Session[]): DailyStore => {
92
+ const daily: DailyStore = {};
93
+
94
+ for (const session of sessions) {
95
+ const date = toDayKey(session);
96
+ const entry = ensureDateEntry(daily, date);
97
+ const modelKey = session.model && session.model.trim().length > 0 ? session.model : "unknown";
98
+ const repoKey = toRepoKey(session.repoName);
99
+ const stats = toSessionStats(session);
100
+
101
+ if (!entry.bySource[session.source]) {
102
+ entry.bySource[session.source] = createEmptyDayStats();
103
+ }
104
+ if (!entry.byModel[modelKey]) {
105
+ entry.byModel[modelKey] = createEmptyDayStats();
106
+ }
107
+ if (repoKey && !entry.byRepo[repoKey]) {
108
+ entry.byRepo[repoKey] = createEmptyDayStats();
109
+ }
110
+
111
+ addStats(entry.bySource[session.source] as DayStats, stats);
112
+ addStats(entry.byModel[modelKey] as DayStats, stats);
113
+ if (repoKey) {
114
+ addStats(entry.byRepo[repoKey] as DayStats, stats);
115
+ }
116
+ addStats(entry.totals, stats);
117
+ }
118
+
119
+ return sortDailyStore(daily);
120
+ };
121
+
122
+ export const mergeDailyAggregates = (existing: DailyStore, incoming: DailyStore): DailyStore => {
123
+ const merged: DailyStore = structuredClone(existing);
124
+
125
+ for (const [date, entry] of Object.entries(incoming)) {
126
+ merged[date] = structuredClone(entry);
127
+ }
128
+
129
+ return sortDailyStore(merged);
130
+ };
@@ -0,0 +1,18 @@
1
+ import type { AgentDiscoverer } from "./types";
2
+ import { dedupeCandidates, expandHome, scanGlobCandidates } from "./utils";
3
+
4
+ const CLAUDE_ROOT = expandHome("~/.claude/projects");
5
+
6
+ export const claudeDiscoverer: AgentDiscoverer = {
7
+ source: "claude",
8
+ async discover() {
9
+ const [mainSessions, subagentSessions] = await Promise.all([
10
+ scanGlobCandidates(CLAUDE_ROOT, "*/*.jsonl", "claude", 100),
11
+ scanGlobCandidates(CLAUDE_ROOT, "*/*/subagents/agent-*.jsonl", "claude", 100),
12
+ ]);
13
+
14
+ return dedupeCandidates([...mainSessions, ...subagentSessions]);
15
+ },
16
+ };
17
+
18
+ export const discoverClaude = claudeDiscoverer.discover;
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+ import type { AgentDiscoverer } from "./types";
3
+ import { expandHome, scanGlobCandidates } from "./utils";
4
+
5
+ const resolveCodexRoot = (): string => {
6
+ const codexHome = process.env.CODEX_HOME;
7
+ if (codexHome && codexHome.trim().length > 0) {
8
+ return join(codexHome, "sessions");
9
+ }
10
+ return expandHome("~/.codex/sessions");
11
+ };
12
+
13
+ export const codexDiscoverer: AgentDiscoverer = {
14
+ source: "codex",
15
+ async discover() {
16
+ return scanGlobCandidates(resolveCodexRoot(), "????/??/??/rollout-*.jsonl", "codex");
17
+ },
18
+ };
19
+
20
+ export const discoverCodex = codexDiscoverer.discover;
@@ -0,0 +1,13 @@
1
+ import type { AgentDiscoverer } from "./types";
2
+ import { expandHome, scanGlobCandidates } from "./utils";
3
+
4
+ const COPILOT_ROOT = expandHome("~/.copilot/session-state");
5
+
6
+ export const copilotDiscoverer: AgentDiscoverer = {
7
+ source: "copilot",
8
+ async discover() {
9
+ return scanGlobCandidates(COPILOT_ROOT, "*.jsonl", "copilot");
10
+ },
11
+ };
12
+
13
+ export const discoverCopilot = copilotDiscoverer.discover;
@@ -0,0 +1,13 @@
1
+ import type { AgentDiscoverer } from "./types";
2
+ import { expandHome, scanGlobCandidates } from "./utils";
3
+
4
+ const DROID_ROOT = expandHome("~/.factory/sessions");
5
+
6
+ export const droidDiscoverer: AgentDiscoverer = {
7
+ source: "droid",
8
+ async discover() {
9
+ return scanGlobCandidates(DROID_ROOT, "*.jsonl", "droid");
10
+ },
11
+ };
12
+
13
+ export const discoverDroid = droidDiscoverer.discover;
@@ -0,0 +1,13 @@
1
+ import type { AgentDiscoverer } from "./types";
2
+ import { expandHome, scanGlobCandidates } from "./utils";
3
+
4
+ const GEMINI_ROOT = expandHome("~/.gemini/tmp");
5
+
6
+ export const geminiDiscoverer: AgentDiscoverer = {
7
+ source: "gemini",
8
+ async discover() {
9
+ return scanGlobCandidates(GEMINI_ROOT, "*/chats/session-*.json", "gemini");
10
+ },
11
+ };
12
+
13
+ export const discoverGemini = geminiDiscoverer.discover;
@@ -0,0 +1,28 @@
1
+ import type { SessionSource } from "../../shared/schema";
2
+ import { claudeDiscoverer } from "./claude";
3
+ import { codexDiscoverer } from "./codex";
4
+ import { copilotDiscoverer } from "./copilot";
5
+ import { droidDiscoverer } from "./droid";
6
+ import { geminiDiscoverer } from "./gemini";
7
+ import { opencodeDiscoverer } from "./opencode";
8
+ import type { AgentDiscoverer, FileCandidate } from "./types";
9
+
10
+ const DISCOVERERS: AgentDiscoverer[] = [
11
+ claudeDiscoverer,
12
+ codexDiscoverer,
13
+ geminiDiscoverer,
14
+ opencodeDiscoverer,
15
+ droidDiscoverer,
16
+ copilotDiscoverer,
17
+ ];
18
+
19
+ export const discoverAll = async (sources?: SessionSource[]): Promise<FileCandidate[]> => {
20
+ const discoverers = sources?.length
21
+ ? DISCOVERERS.filter((discoverer) => sources.includes(discoverer.source))
22
+ : DISCOVERERS;
23
+
24
+ const results = await Promise.all(discoverers.map((discoverer) => discoverer.discover().catch(() => [])));
25
+ return results.flat().sort((a, b) => a.path.localeCompare(b.path));
26
+ };
27
+
28
+ export type { FileCandidate } from "./types";
@@ -0,0 +1,13 @@
1
+ import type { AgentDiscoverer } from "./types";
2
+ import { expandHome, scanGlobCandidates } from "./utils";
3
+
4
+ const OPENCODE_STORAGE_ROOT = expandHome("~/.local/share/opencode/storage");
5
+
6
+ export const opencodeDiscoverer: AgentDiscoverer = {
7
+ source: "opencode",
8
+ async discover() {
9
+ return scanGlobCandidates(OPENCODE_STORAGE_ROOT, "session/*/*.json", "opencode");
10
+ },
11
+ };
12
+
13
+ export const discoverOpencode = opencodeDiscoverer.discover;
@@ -0,0 +1,13 @@
1
+ import type { SessionSource } from "../../shared/schema";
2
+
3
+ export interface FileCandidate {
4
+ path: string;
5
+ source: SessionSource;
6
+ mtime: number;
7
+ size: number;
8
+ }
9
+
10
+ export interface AgentDiscoverer {
11
+ source: SessionSource;
12
+ discover(): Promise<FileCandidate[]>;
13
+ }
@@ -0,0 +1,48 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { SessionSource } from "../../shared/schema";
5
+ import type { FileCandidate } from "./types";
6
+
7
+ export const expandHome = (path: string): string => {
8
+ if (path === "~") return homedir();
9
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
10
+ return path;
11
+ };
12
+
13
+ export const scanGlobCandidates = async (
14
+ cwd: string,
15
+ pattern: string,
16
+ source: SessionSource,
17
+ minBytes = 0,
18
+ ): Promise<FileCandidate[]> => {
19
+ const files: FileCandidate[] = [];
20
+
21
+ try {
22
+ const glob = new Bun.Glob(pattern);
23
+ for await (const path of glob.scan({ cwd, absolute: true, onlyFiles: true })) {
24
+ const stats = await stat(path).catch(() => null);
25
+ if (!stats || !stats.isFile()) continue;
26
+ if (stats.size < minBytes) continue;
27
+ files.push({
28
+ path,
29
+ source,
30
+ mtime: stats.mtimeMs,
31
+ size: stats.size,
32
+ });
33
+ }
34
+ } catch {
35
+ return [];
36
+ }
37
+
38
+ files.sort((a, b) => a.path.localeCompare(b.path));
39
+ return files;
40
+ };
41
+
42
+ export const dedupeCandidates = (candidates: FileCandidate[]): FileCandidate[] => {
43
+ const byPath = new Map<string, FileCandidate>();
44
+ for (const candidate of candidates) {
45
+ byPath.set(candidate.path, candidate);
46
+ }
47
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
48
+ };