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,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SessionEvent } from "./session-schema";
|
|
3
|
+
import { normalizeSession, normalizeTokenUsage } from "./normalizer";
|
|
4
|
+
import type { RawParsedSession } from "./parsers/types";
|
|
5
|
+
|
|
6
|
+
const makeEvent = (overrides: Partial<SessionEvent>): SessionEvent => ({
|
|
7
|
+
id: "event-1",
|
|
8
|
+
sessionId: "placeholder",
|
|
9
|
+
kind: "assistant",
|
|
10
|
+
timestamp: "2026-02-21T10:00:00.000Z",
|
|
11
|
+
role: "assistant",
|
|
12
|
+
text: "hello",
|
|
13
|
+
toolName: null,
|
|
14
|
+
toolInput: null,
|
|
15
|
+
toolOutput: null,
|
|
16
|
+
model: "gpt-5",
|
|
17
|
+
parentId: null,
|
|
18
|
+
messageId: null,
|
|
19
|
+
isDelta: false,
|
|
20
|
+
tokens: null,
|
|
21
|
+
costUsd: null,
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const makeRawSession = (sessionId: string, events: SessionEvent[]): RawParsedSession => ({
|
|
26
|
+
sessionId,
|
|
27
|
+
source: "codex",
|
|
28
|
+
filePath: `/tmp/${sessionId}.jsonl`,
|
|
29
|
+
fileSizeBytes: 123,
|
|
30
|
+
metadata: {
|
|
31
|
+
cwd: "/tmp/repo",
|
|
32
|
+
gitBranch: "main",
|
|
33
|
+
model: "gpt-5",
|
|
34
|
+
cliVersion: "1.0.0",
|
|
35
|
+
title: null,
|
|
36
|
+
},
|
|
37
|
+
events,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("normalizeSession event ids", () => {
|
|
41
|
+
test("scopes event ids by session", () => {
|
|
42
|
+
const first = normalizeSession(
|
|
43
|
+
makeRawSession("session-a", [makeEvent({ id: "shared-id", sessionId: "session-a" })]),
|
|
44
|
+
);
|
|
45
|
+
const second = normalizeSession(
|
|
46
|
+
makeRawSession("session-b", [makeEvent({ id: "shared-id", sessionId: "session-b" })]),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(first.events[0]?.id).toBe("session-a:event:shared-id");
|
|
50
|
+
expect(second.events[0]?.id).toBe("session-b:event:shared-id");
|
|
51
|
+
expect(first.events[0]?.id).not.toBe(second.events[0]?.id);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("deduplicates repeated ids within the same session", () => {
|
|
55
|
+
const result = normalizeSession(
|
|
56
|
+
makeRawSession("session-c", [
|
|
57
|
+
makeEvent({ id: "dup", timestamp: "2026-02-21T10:00:00.000Z" }),
|
|
58
|
+
makeEvent({ id: "dup", timestamp: "2026-02-21T10:00:01.000Z" }),
|
|
59
|
+
]),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const ids = result.events.map((event) => event.id);
|
|
63
|
+
expect(ids).toEqual(["session-c:event:dup", "session-c:event:dup:dup:1"]);
|
|
64
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("normalizeTokenUsage", () => {
|
|
69
|
+
test("supports prompt/completion and cached/reasoning fields", () => {
|
|
70
|
+
const usage = normalizeTokenUsage({
|
|
71
|
+
prompt_tokens: 1200,
|
|
72
|
+
completion_tokens: 300,
|
|
73
|
+
cached_input_tokens: 700,
|
|
74
|
+
reasoning_output_tokens: 90,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(usage).toEqual({
|
|
78
|
+
inputTokens: 1200,
|
|
79
|
+
outputTokens: 300,
|
|
80
|
+
cacheReadTokens: 700,
|
|
81
|
+
cacheWriteTokens: 0,
|
|
82
|
+
reasoningTokens: 90,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("supports nested prompt_tokens_details cached tokens", () => {
|
|
87
|
+
const usage = normalizeTokenUsage({
|
|
88
|
+
input_tokens: 512,
|
|
89
|
+
output_tokens: 128,
|
|
90
|
+
prompt_tokens_details: { cached_tokens: 256 },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(usage).toEqual({
|
|
94
|
+
inputTokens: 512,
|
|
95
|
+
outputTokens: 128,
|
|
96
|
+
cacheReadTokens: 256,
|
|
97
|
+
cacheWriteTokens: 0,
|
|
98
|
+
reasoningTokens: 0,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
EMPTY_TOKEN_USAGE,
|
|
4
|
+
type TokenUsage,
|
|
5
|
+
} from "../shared/schema";
|
|
6
|
+
import type { Session, SessionEvent, SessionEventKind } from "./session-schema";
|
|
7
|
+
import { computeCost } from "./pricing";
|
|
8
|
+
import type { RawParsedSession } from "./parsers/types";
|
|
9
|
+
|
|
10
|
+
const TOOL_CALL_TYPES = new Set([
|
|
11
|
+
"tool_call",
|
|
12
|
+
"tool-call",
|
|
13
|
+
"tool_use",
|
|
14
|
+
"function_call",
|
|
15
|
+
"custom_tool_call",
|
|
16
|
+
"web_search_call",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const TOOL_RESULT_TYPES = new Set([
|
|
20
|
+
"tool_result",
|
|
21
|
+
"tool-result",
|
|
22
|
+
"function_result",
|
|
23
|
+
"function_call_output",
|
|
24
|
+
"custom_tool_call_output",
|
|
25
|
+
"web_search_call_output",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const ERROR_TYPES = new Set(["error", "err"]);
|
|
29
|
+
|
|
30
|
+
const META_TYPES = new Set([
|
|
31
|
+
"system",
|
|
32
|
+
"summary",
|
|
33
|
+
"file-history-snapshot",
|
|
34
|
+
"session_meta",
|
|
35
|
+
"turn_context",
|
|
36
|
+
"todo_state",
|
|
37
|
+
"session_start",
|
|
38
|
+
"progress",
|
|
39
|
+
"queue-operation",
|
|
40
|
+
"assistant.turn_start",
|
|
41
|
+
"assistant.turn_end",
|
|
42
|
+
"session.truncation",
|
|
43
|
+
"environment_context",
|
|
44
|
+
"thread_rolled_back",
|
|
45
|
+
"task_started",
|
|
46
|
+
"task_complete",
|
|
47
|
+
"turn_aborted",
|
|
48
|
+
"reasoning",
|
|
49
|
+
"agent_reasoning",
|
|
50
|
+
"token_count",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const toFiniteNumber = (value: unknown): number | null => {
|
|
54
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const normalizeTimestamp = (input: unknown): string | null => {
|
|
63
|
+
if (input === null || input === undefined || input === "") return null;
|
|
64
|
+
|
|
65
|
+
if (typeof input === "string") {
|
|
66
|
+
const numeric = toFiniteNumber(input);
|
|
67
|
+
if (numeric !== null) {
|
|
68
|
+
return normalizeTimestamp(numeric);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const time = Date.parse(input);
|
|
72
|
+
if (!Number.isNaN(time)) {
|
|
73
|
+
return new Date(time).toISOString();
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const numeric = toFiniteNumber(input);
|
|
79
|
+
if (numeric === null) return null;
|
|
80
|
+
|
|
81
|
+
// Heuristic: <= 10 digits is epoch seconds, > 10 digits is epoch milliseconds.
|
|
82
|
+
const asMs = numeric < 100_000_000_000 ? numeric * 1000 : numeric;
|
|
83
|
+
const date = new Date(asMs);
|
|
84
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
85
|
+
return date.toISOString();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const collectText = (value: unknown, output: string[], depth = 0): void => {
|
|
89
|
+
if (value === null || value === undefined || depth > 6) return;
|
|
90
|
+
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
if (trimmed.length > 0) output.push(trimmed);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
98
|
+
output.push(String(value));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
for (const entry of value) {
|
|
104
|
+
collectText(entry, output, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof value === "object") {
|
|
110
|
+
const record = value as Record<string, unknown>;
|
|
111
|
+
|
|
112
|
+
const preferredKeys: Array<keyof typeof record> = [
|
|
113
|
+
"text",
|
|
114
|
+
"message",
|
|
115
|
+
"summary",
|
|
116
|
+
"description",
|
|
117
|
+
"thinking",
|
|
118
|
+
"content",
|
|
119
|
+
"output",
|
|
120
|
+
"input",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const key of preferredKeys) {
|
|
124
|
+
if (key in record) {
|
|
125
|
+
collectText(record[key], output, depth + 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if ("args" in record) {
|
|
130
|
+
collectText(record.args, output, depth + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const extractText = (value: unknown): string | null => {
|
|
136
|
+
const chunks: string[] = [];
|
|
137
|
+
collectText(value, chunks);
|
|
138
|
+
if (chunks.length === 0) return null;
|
|
139
|
+
return chunks.join("\n").trim() || null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const getNested = (value: unknown, path: string[]): unknown => {
|
|
143
|
+
let current: unknown = value;
|
|
144
|
+
for (const part of path) {
|
|
145
|
+
if (!current || typeof current !== "object" || !(part in (current as Record<string, unknown>))) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
current = (current as Record<string, unknown>)[part];
|
|
149
|
+
}
|
|
150
|
+
return current;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const normalizeTokenUsage = (value: unknown): TokenUsage | null => {
|
|
154
|
+
if (!value || typeof value !== "object") return null;
|
|
155
|
+
|
|
156
|
+
const source = value as Record<string, unknown>;
|
|
157
|
+
|
|
158
|
+
const firstNumber = (...candidates: unknown[]): number => {
|
|
159
|
+
for (const candidate of candidates) {
|
|
160
|
+
const parsed = toFiniteNumber(candidate);
|
|
161
|
+
if (parsed !== null) return parsed;
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const inputTokens = firstNumber(
|
|
167
|
+
source.inputTokens,
|
|
168
|
+
source.input_tokens,
|
|
169
|
+
source.input,
|
|
170
|
+
source.prompt_tokens,
|
|
171
|
+
source.promptTokens,
|
|
172
|
+
);
|
|
173
|
+
const outputTokens = firstNumber(
|
|
174
|
+
source.outputTokens,
|
|
175
|
+
source.output_tokens,
|
|
176
|
+
source.output,
|
|
177
|
+
source.completion_tokens,
|
|
178
|
+
source.completionTokens,
|
|
179
|
+
);
|
|
180
|
+
const cacheReadTokens = firstNumber(
|
|
181
|
+
source.cacheReadTokens,
|
|
182
|
+
source.cache_read_tokens,
|
|
183
|
+
source.cache_read_input_tokens,
|
|
184
|
+
source.cached_input_tokens,
|
|
185
|
+
source.cachedTokens,
|
|
186
|
+
source.cached_tokens,
|
|
187
|
+
source.cached,
|
|
188
|
+
getNested(source, ["prompt_tokens_details", "cached_tokens"]),
|
|
189
|
+
getNested(source, ["cache", "read"]),
|
|
190
|
+
);
|
|
191
|
+
const cacheWriteTokens = firstNumber(
|
|
192
|
+
source.cacheWriteTokens,
|
|
193
|
+
source.cache_write_tokens,
|
|
194
|
+
source.cache_write_input_tokens,
|
|
195
|
+
source.cache_creation_input_tokens,
|
|
196
|
+
source.cacheCreationTokens,
|
|
197
|
+
getNested(source, ["cache", "write"]),
|
|
198
|
+
);
|
|
199
|
+
const reasoningTokens = firstNumber(
|
|
200
|
+
source.reasoningTokens,
|
|
201
|
+
source.reasoning_tokens,
|
|
202
|
+
source.reasoning_output_tokens,
|
|
203
|
+
source.reasoning,
|
|
204
|
+
source.thinkingTokens,
|
|
205
|
+
source.thoughts,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const hasAnyField =
|
|
209
|
+
"inputTokens" in source ||
|
|
210
|
+
"input_tokens" in source ||
|
|
211
|
+
"input" in source ||
|
|
212
|
+
"prompt_tokens" in source ||
|
|
213
|
+
"promptTokens" in source ||
|
|
214
|
+
"outputTokens" in source ||
|
|
215
|
+
"output_tokens" in source ||
|
|
216
|
+
"output" in source ||
|
|
217
|
+
"completion_tokens" in source ||
|
|
218
|
+
"completionTokens" in source ||
|
|
219
|
+
"cacheReadTokens" in source ||
|
|
220
|
+
"cache_read_tokens" in source ||
|
|
221
|
+
"cache_read_input_tokens" in source ||
|
|
222
|
+
"cached_input_tokens" in source ||
|
|
223
|
+
"cachedTokens" in source ||
|
|
224
|
+
"cached_tokens" in source ||
|
|
225
|
+
"cache" in source ||
|
|
226
|
+
"prompt_tokens_details" in source ||
|
|
227
|
+
"cacheWriteTokens" in source ||
|
|
228
|
+
"cache_write_tokens" in source ||
|
|
229
|
+
"cache_creation_input_tokens" in source ||
|
|
230
|
+
"reasoningTokens" in source ||
|
|
231
|
+
"reasoning_tokens" in source ||
|
|
232
|
+
"reasoning_output_tokens" in source ||
|
|
233
|
+
"thoughts" in source ||
|
|
234
|
+
"thinkingTokens" in source;
|
|
235
|
+
|
|
236
|
+
if (!hasAnyField && inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens === 0) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
inputTokens,
|
|
242
|
+
outputTokens,
|
|
243
|
+
cacheReadTokens,
|
|
244
|
+
cacheWriteTokens,
|
|
245
|
+
reasoningTokens,
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const resolveEventKind = (rawType: unknown, rawRole: unknown): SessionEventKind => {
|
|
250
|
+
const type = typeof rawType === "string" ? rawType.toLowerCase().trim() : "";
|
|
251
|
+
const role = typeof rawRole === "string" ? rawRole.toLowerCase().trim() : "";
|
|
252
|
+
|
|
253
|
+
if (type === "user" || role === "user") return "user";
|
|
254
|
+
if (type === "assistant" || role === "assistant") return "assistant";
|
|
255
|
+
if (TOOL_CALL_TYPES.has(type)) return "tool_call";
|
|
256
|
+
if (TOOL_RESULT_TYPES.has(type) || role === "tool") return "tool_result";
|
|
257
|
+
if (ERROR_TYPES.has(type)) return "error";
|
|
258
|
+
if (META_TYPES.has(type) || role === "system") return "meta";
|
|
259
|
+
|
|
260
|
+
return "meta";
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const addTokenUsage = (left: TokenUsage, right: TokenUsage | null): TokenUsage => {
|
|
264
|
+
if (!right) return left;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
inputTokens: left.inputTokens + right.inputTokens,
|
|
268
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
269
|
+
cacheReadTokens: left.cacheReadTokens + right.cacheReadTokens,
|
|
270
|
+
cacheWriteTokens: left.cacheWriteTokens + right.cacheWriteTokens,
|
|
271
|
+
reasoningTokens: left.reasoningTokens + right.reasoningTokens,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const toEventIdSuffix = (value: unknown): string | null => {
|
|
276
|
+
if (typeof value === "string") {
|
|
277
|
+
const trimmed = value.trim();
|
|
278
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
282
|
+
return String(value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return null;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const scopeEventId = (sessionId: string, value: unknown, fallbackIndex?: number): string | null => {
|
|
289
|
+
const suffix = toEventIdSuffix(value) ?? (typeof fallbackIndex === "number" ? `index:${fallbackIndex}` : null);
|
|
290
|
+
if (!suffix) return null;
|
|
291
|
+
|
|
292
|
+
const prefix = `${sessionId}:event:`;
|
|
293
|
+
if (suffix.startsWith(prefix)) {
|
|
294
|
+
return suffix;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return `${prefix}${suffix}`;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const normalizeEventShape = (event: SessionEvent, index: number, sessionId: string): SessionEvent => {
|
|
301
|
+
const tokens = normalizeTokenUsage(event.tokens);
|
|
302
|
+
const scopedId = scopeEventId(sessionId, event.id, index) ?? `${sessionId}:event:index:${index}`;
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
id: scopedId,
|
|
306
|
+
sessionId,
|
|
307
|
+
kind: event.kind,
|
|
308
|
+
timestamp: normalizeTimestamp(event.timestamp),
|
|
309
|
+
role: event.role ?? null,
|
|
310
|
+
text: event.text ?? null,
|
|
311
|
+
toolName: event.toolName ?? null,
|
|
312
|
+
toolInput: event.toolInput ?? null,
|
|
313
|
+
toolOutput: event.toolOutput ?? null,
|
|
314
|
+
model: event.model ?? null,
|
|
315
|
+
parentId: scopeEventId(sessionId, event.parentId),
|
|
316
|
+
messageId: event.messageId ?? null,
|
|
317
|
+
isDelta: Boolean(event.isDelta),
|
|
318
|
+
tokens,
|
|
319
|
+
costUsd: typeof event.costUsd === "number" && Number.isFinite(event.costUsd) ? event.costUsd : null,
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const extractRepoName = (cwd: string | null): string | null => {
|
|
324
|
+
if (!cwd) return null;
|
|
325
|
+
const repo = basename(cwd.replace(/[\\/]+$/, ""));
|
|
326
|
+
return repo.length > 0 ? repo : null;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const findSessionModel = (events: SessionEvent[], fallback: string | null): string | null => {
|
|
330
|
+
const counts = new Map<string, number>();
|
|
331
|
+
for (const event of events) {
|
|
332
|
+
if (event.kind !== "assistant" || !event.model) continue;
|
|
333
|
+
counts.set(event.model, (counts.get(event.model) ?? 0) + 1);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let winner: string | null = null;
|
|
337
|
+
let max = 0;
|
|
338
|
+
for (const [model, count] of counts) {
|
|
339
|
+
if (count > max) {
|
|
340
|
+
max = count;
|
|
341
|
+
winner = model;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return winner ?? fallback;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const deriveTitle = (events: SessionEvent[], explicit: string | null): string | null => {
|
|
349
|
+
if (explicit && explicit.trim().length > 0) return explicit.trim();
|
|
350
|
+
|
|
351
|
+
const firstUser = events.find((event) => event.kind === "user" && event.text && event.text.trim().length > 0);
|
|
352
|
+
if (!firstUser?.text) return null;
|
|
353
|
+
return firstUser.text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
export const normalizeSession = (parsed: RawParsedSession): { session: Session; events: SessionEvent[] } => {
|
|
357
|
+
const normalizedEvents = parsed.events
|
|
358
|
+
.map((event, index) => normalizeEventShape(event, index, parsed.sessionId))
|
|
359
|
+
.map((event) => {
|
|
360
|
+
const costFromPricing = event.costUsd ?? computeCost(event.tokens, event.model ?? parsed.metadata.model);
|
|
361
|
+
return { ...event, costUsd: costFromPricing };
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const eventsWithIndex = normalizedEvents.map((event, index) => ({ event, index }));
|
|
365
|
+
eventsWithIndex.sort((left, right) => {
|
|
366
|
+
if (!left.event.timestamp && !right.event.timestamp) return left.index - right.index;
|
|
367
|
+
if (!left.event.timestamp) return 1;
|
|
368
|
+
if (!right.event.timestamp) return -1;
|
|
369
|
+
|
|
370
|
+
const leftTime = Date.parse(left.event.timestamp);
|
|
371
|
+
const rightTime = Date.parse(right.event.timestamp);
|
|
372
|
+
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) return left.index - right.index;
|
|
373
|
+
if (leftTime !== rightTime) return leftTime - rightTime;
|
|
374
|
+
return left.index - right.index;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const seenEventIds = new Map<string, number>();
|
|
378
|
+
const events = eventsWithIndex.map(({ event }) => {
|
|
379
|
+
const seenCount = seenEventIds.get(event.id) ?? 0;
|
|
380
|
+
seenEventIds.set(event.id, seenCount + 1);
|
|
381
|
+
|
|
382
|
+
if (seenCount === 0) {
|
|
383
|
+
return event;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
...event,
|
|
388
|
+
id: `${event.id}:dup:${seenCount}`,
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const timestamped = events
|
|
393
|
+
.map((event) => event.timestamp)
|
|
394
|
+
.filter((timestamp): timestamp is string => Boolean(timestamp));
|
|
395
|
+
|
|
396
|
+
const startTime = timestamped.length > 0 ? timestamped[0] ?? null : null;
|
|
397
|
+
const endTime = timestamped.length > 0 ? timestamped[timestamped.length - 1] ?? null : null;
|
|
398
|
+
|
|
399
|
+
let durationMs: number | null = null;
|
|
400
|
+
if (startTime && endTime) {
|
|
401
|
+
const diff = Date.parse(endTime) - Date.parse(startTime);
|
|
402
|
+
durationMs = Number.isFinite(diff) ? Math.max(0, diff) : null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let totalTokens = { ...EMPTY_TOKEN_USAGE };
|
|
406
|
+
let totalCost = 0;
|
|
407
|
+
let hasCost = false;
|
|
408
|
+
let messageCount = 0;
|
|
409
|
+
let toolCallCount = 0;
|
|
410
|
+
|
|
411
|
+
for (const event of events) {
|
|
412
|
+
totalTokens = addTokenUsage(totalTokens, event.tokens);
|
|
413
|
+
|
|
414
|
+
if (typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) {
|
|
415
|
+
totalCost += event.costUsd;
|
|
416
|
+
hasCost = true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (event.kind === "user" || event.kind === "assistant") {
|
|
420
|
+
messageCount += 1;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (event.kind === "tool_call") {
|
|
424
|
+
toolCallCount += 1;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const housekeeping = messageCount === 0 || events.every((event) => event.kind === "meta");
|
|
429
|
+
|
|
430
|
+
const session: Session = {
|
|
431
|
+
id: parsed.sessionId,
|
|
432
|
+
source: parsed.source,
|
|
433
|
+
filePath: parsed.filePath,
|
|
434
|
+
fileSizeBytes: parsed.fileSizeBytes,
|
|
435
|
+
startTime,
|
|
436
|
+
endTime,
|
|
437
|
+
durationMs,
|
|
438
|
+
title: deriveTitle(events, parsed.metadata.title),
|
|
439
|
+
model: findSessionModel(events, parsed.metadata.model),
|
|
440
|
+
cwd: parsed.metadata.cwd,
|
|
441
|
+
repoName: extractRepoName(parsed.metadata.cwd),
|
|
442
|
+
gitBranch: parsed.metadata.gitBranch,
|
|
443
|
+
cliVersion: parsed.metadata.cliVersion,
|
|
444
|
+
eventCount: events.length,
|
|
445
|
+
messageCount,
|
|
446
|
+
totalTokens,
|
|
447
|
+
totalCostUsd: hasCost ? totalCost : null,
|
|
448
|
+
toolCallCount,
|
|
449
|
+
isHousekeeping: housekeeping,
|
|
450
|
+
parsedAt: new Date().toISOString(),
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
return { session, events };
|
|
454
|
+
};
|