tokentrace 0.1.0

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 (224) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +167 -0
  3. package/.next/app-path-routes-manifest.json +22 -0
  4. package/.next/build-manifest.json +33 -0
  5. package/.next/export-marker.json +6 -0
  6. package/.next/images-manifest.json +58 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +37 -0
  11. package/.next/react-loadable-manifest.json +1 -0
  12. package/.next/required-server-files.json +323 -0
  13. package/.next/routes-manifest.json +119 -0
  14. package/.next/server/app/_not-found/page.js +2 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +8 -0
  19. package/.next/server/app/_not-found.rsc +37 -0
  20. package/.next/server/app/api/analytics/route.js +1 -0
  21. package/.next/server/app/api/analytics/route.js.nft.json +1 -0
  22. package/.next/server/app/api/analytics/route_client-reference-manifest.js +1 -0
  23. package/.next/server/app/api/data/route.js +151 -0
  24. package/.next/server/app/api/data/route.js.nft.json +1 -0
  25. package/.next/server/app/api/data/route_client-reference-manifest.js +1 -0
  26. package/.next/server/app/api/export/route.js +1 -0
  27. package/.next/server/app/api/export/route.js.nft.json +1 -0
  28. package/.next/server/app/api/export/route_client-reference-manifest.js +1 -0
  29. package/.next/server/app/api/files/route.js +1 -0
  30. package/.next/server/app/api/files/route.js.nft.json +1 -0
  31. package/.next/server/app/api/files/route_client-reference-manifest.js +1 -0
  32. package/.next/server/app/api/prices/route.js +151 -0
  33. package/.next/server/app/api/prices/route.js.nft.json +1 -0
  34. package/.next/server/app/api/prices/route_client-reference-manifest.js +1 -0
  35. package/.next/server/app/api/scan/route.js +144 -0
  36. package/.next/server/app/api/scan/route.js.nft.json +1 -0
  37. package/.next/server/app/api/scan/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/settings/route.js +128 -0
  39. package/.next/server/app/api/settings/route.js.nft.json +1 -0
  40. package/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/debug/page.js +2 -0
  42. package/.next/server/app/debug/page.js.nft.json +1 -0
  43. package/.next/server/app/debug/page_client-reference-manifest.js +1 -0
  44. package/.next/server/app/diagnostics/page.js +2 -0
  45. package/.next/server/app/diagnostics/page.js.nft.json +1 -0
  46. package/.next/server/app/diagnostics/page_client-reference-manifest.js +1 -0
  47. package/.next/server/app/discovery/page.js +2 -0
  48. package/.next/server/app/discovery/page.js.nft.json +1 -0
  49. package/.next/server/app/discovery/page_client-reference-manifest.js +1 -0
  50. package/.next/server/app/models/page.js +2 -0
  51. package/.next/server/app/models/page.js.nft.json +1 -0
  52. package/.next/server/app/models/page_client-reference-manifest.js +1 -0
  53. package/.next/server/app/optimisation/page.js +2 -0
  54. package/.next/server/app/optimisation/page.js.nft.json +1 -0
  55. package/.next/server/app/optimisation/page_client-reference-manifest.js +1 -0
  56. package/.next/server/app/page.js +2 -0
  57. package/.next/server/app/page.js.nft.json +1 -0
  58. package/.next/server/app/page_client-reference-manifest.js +1 -0
  59. package/.next/server/app/parser-debug/page.js +2 -0
  60. package/.next/server/app/parser-debug/page.js.nft.json +1 -0
  61. package/.next/server/app/parser-debug/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/pricing/page.js +152 -0
  63. package/.next/server/app/pricing/page.js.nft.json +1 -0
  64. package/.next/server/app/pricing/page_client-reference-manifest.js +1 -0
  65. package/.next/server/app/projects/page.js +2 -0
  66. package/.next/server/app/projects/page.js.nft.json +1 -0
  67. package/.next/server/app/projects/page_client-reference-manifest.js +1 -0
  68. package/.next/server/app/sessions/page.js +2 -0
  69. package/.next/server/app/sessions/page.js.nft.json +1 -0
  70. package/.next/server/app/sessions/page_client-reference-manifest.js +1 -0
  71. package/.next/server/app/settings/page.js +129 -0
  72. package/.next/server/app/settings/page.js.nft.json +1 -0
  73. package/.next/server/app/settings/page_client-reference-manifest.js +1 -0
  74. package/.next/server/app/tools/page.js +2 -0
  75. package/.next/server/app/tools/page.js.nft.json +1 -0
  76. package/.next/server/app/tools/page_client-reference-manifest.js +1 -0
  77. package/.next/server/app-paths-manifest.json +22 -0
  78. package/.next/server/chunks/123.js +9 -0
  79. package/.next/server/chunks/153.js +1 -0
  80. package/.next/server/chunks/237.js +13 -0
  81. package/.next/server/chunks/331.js +22 -0
  82. package/.next/server/chunks/366.js +1 -0
  83. package/.next/server/chunks/444.js +267 -0
  84. package/.next/server/chunks/611.js +6 -0
  85. package/.next/server/chunks/692.js +1 -0
  86. package/.next/server/chunks/779.js +1 -0
  87. package/.next/server/chunks/815.js +1 -0
  88. package/.next/server/chunks/868.js +1 -0
  89. package/.next/server/functions-config-manifest.json +4 -0
  90. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  91. package/.next/server/middleware-build-manifest.js +1 -0
  92. package/.next/server/middleware-manifest.json +6 -0
  93. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  94. package/.next/server/next-font-manifest.js +1 -0
  95. package/.next/server/next-font-manifest.json +1 -0
  96. package/.next/server/pages/404.html +1 -0
  97. package/.next/server/pages/500.html +1 -0
  98. package/.next/server/pages/_app.js +1 -0
  99. package/.next/server/pages/_app.js.nft.json +1 -0
  100. package/.next/server/pages/_document.js +1 -0
  101. package/.next/server/pages/_document.js.nft.json +1 -0
  102. package/.next/server/pages/_error.js +19 -0
  103. package/.next/server/pages/_error.js.nft.json +1 -0
  104. package/.next/server/pages-manifest.json +6 -0
  105. package/.next/server/server-reference-manifest.js +1 -0
  106. package/.next/server/server-reference-manifest.json +1 -0
  107. package/.next/server/webpack-runtime.js +1 -0
  108. package/.next/static/Fh8usqK3dgfncUx9s3VR1/_buildManifest.js +1 -0
  109. package/.next/static/Fh8usqK3dgfncUx9s3VR1/_ssgManifest.js +1 -0
  110. package/.next/static/chunks/125-ab0f8db8f84c1166.js +1 -0
  111. package/.next/static/chunks/255-e881f48ae1d2333a.js +1 -0
  112. package/.next/static/chunks/4bd1b696-409494caf8c83275.js +1 -0
  113. package/.next/static/chunks/619-f072ac750404f9da.js +1 -0
  114. package/.next/static/chunks/850-8bc31e41590b5831.js +1 -0
  115. package/.next/static/chunks/938-23236de1c47554ea.js +1 -0
  116. package/.next/static/chunks/app/_not-found/page-6d75243350d9e0b5.js +1 -0
  117. package/.next/static/chunks/app/api/analytics/route-33d3f29973de91a4.js +1 -0
  118. package/.next/static/chunks/app/api/data/route-33d3f29973de91a4.js +1 -0
  119. package/.next/static/chunks/app/api/export/route-33d3f29973de91a4.js +1 -0
  120. package/.next/static/chunks/app/api/files/route-33d3f29973de91a4.js +1 -0
  121. package/.next/static/chunks/app/api/prices/route-33d3f29973de91a4.js +1 -0
  122. package/.next/static/chunks/app/api/scan/route-33d3f29973de91a4.js +1 -0
  123. package/.next/static/chunks/app/api/settings/route-33d3f29973de91a4.js +1 -0
  124. package/.next/static/chunks/app/debug/page-33d3f29973de91a4.js +1 -0
  125. package/.next/static/chunks/app/diagnostics/page-053a5e810a59e548.js +1 -0
  126. package/.next/static/chunks/app/discovery/page-33d3f29973de91a4.js +1 -0
  127. package/.next/static/chunks/app/layout-8942804176ff26f3.js +1 -0
  128. package/.next/static/chunks/app/models/page-c0acf74dd8197e01.js +1 -0
  129. package/.next/static/chunks/app/optimisation/page-33d3f29973de91a4.js +1 -0
  130. package/.next/static/chunks/app/page-b6886ec802c03cbf.js +1 -0
  131. package/.next/static/chunks/app/parser-debug/page-33d3f29973de91a4.js +1 -0
  132. package/.next/static/chunks/app/pricing/page-5e27b1ae27314539.js +1 -0
  133. package/.next/static/chunks/app/projects/page-b6886ec802c03cbf.js +1 -0
  134. package/.next/static/chunks/app/sessions/page-0abcdc88aac9dcaf.js +1 -0
  135. package/.next/static/chunks/app/settings/page-59fc80673f0750cd.js +1 -0
  136. package/.next/static/chunks/app/tools/page-c0acf74dd8197e01.js +1 -0
  137. package/.next/static/chunks/framework-3457b9c2619cdd96.js +1 -0
  138. package/.next/static/chunks/main-8744520a8a31e6ae.js +1 -0
  139. package/.next/static/chunks/main-app-e9ccddef393e28c3.js +1 -0
  140. package/.next/static/chunks/pages/_app-5addca2b3b969fde.js +1 -0
  141. package/.next/static/chunks/pages/_error-022e4ac7bbb9914f.js +1 -0
  142. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  143. package/.next/static/chunks/webpack-3fcacae817f3ffab.js +1 -0
  144. package/.next/static/css/366bb38b386229a5.css +3 -0
  145. package/LICENSE +21 -0
  146. package/README.md +216 -0
  147. package/app/api/analytics/route.ts +8 -0
  148. package/app/api/data/route.ts +9 -0
  149. package/app/api/export/route.ts +26 -0
  150. package/app/api/files/route.ts +8 -0
  151. package/app/api/prices/route.ts +33 -0
  152. package/app/api/scan/route.ts +15 -0
  153. package/app/api/settings/route.ts +25 -0
  154. package/app/debug/page.tsx +101 -0
  155. package/app/diagnostics/page.tsx +113 -0
  156. package/app/discovery/page.tsx +61 -0
  157. package/app/globals.css +51 -0
  158. package/app/layout.tsx +30 -0
  159. package/app/models/page.tsx +97 -0
  160. package/app/optimisation/page.tsx +67 -0
  161. package/app/page.tsx +164 -0
  162. package/app/parser-debug/page.tsx +57 -0
  163. package/app/pricing/page.tsx +18 -0
  164. package/app/projects/page.tsx +111 -0
  165. package/app/sessions/page.tsx +24 -0
  166. package/app/settings/page.tsx +26 -0
  167. package/app/tools/page.tsx +92 -0
  168. package/bin/tokentrace.js +316 -0
  169. package/components/charts/rank-bar-chart.tsx +69 -0
  170. package/components/charts/trend-chart.tsx +123 -0
  171. package/components/empty-state.tsx +14 -0
  172. package/components/pricing-settings.tsx +171 -0
  173. package/components/session-explorer.tsx +210 -0
  174. package/components/settings-panel.tsx +203 -0
  175. package/components/sidebar.tsx +88 -0
  176. package/components/ui/badge.tsx +30 -0
  177. package/components/ui/button.tsx +47 -0
  178. package/components/ui/card.tsx +22 -0
  179. package/components/ui/input.tsx +19 -0
  180. package/components/ui/label.tsx +6 -0
  181. package/components/ui/table.tsx +31 -0
  182. package/components/ui/textarea.tsx +18 -0
  183. package/components.json +16 -0
  184. package/dist/runtime/db-migrate.mjs +410 -0
  185. package/dist/runtime/db-seed.mjs +506 -0
  186. package/dist/runtime/reset.mjs +519 -0
  187. package/dist/runtime/scan.mjs +1817 -0
  188. package/fixtures/generic-jsonl/sample.jsonl +2 -0
  189. package/next.config.mjs +7 -0
  190. package/package.json +96 -0
  191. package/postcss.config.mjs +8 -0
  192. package/scripts/build-cli-runtime.mjs +40 -0
  193. package/scripts/db-migrate.ts +5 -0
  194. package/scripts/db-seed.ts +5 -0
  195. package/scripts/reset.ts +5 -0
  196. package/scripts/scan.ts +30 -0
  197. package/src/db/client.ts +32 -0
  198. package/src/db/migrate-core.ts +147 -0
  199. package/src/db/reset.ts +14 -0
  200. package/src/db/schema.ts +259 -0
  201. package/src/db/seed.ts +110 -0
  202. package/src/db/settings.ts +47 -0
  203. package/src/ingestion/adapters/claude-code.ts +78 -0
  204. package/src/ingestion/adapters/codex-cli.ts +82 -0
  205. package/src/ingestion/adapters/generic-json.ts +93 -0
  206. package/src/ingestion/adapters/generic-jsonl.ts +62 -0
  207. package/src/ingestion/adapters/generic-log.ts +144 -0
  208. package/src/ingestion/adapters/generic-records.ts +178 -0
  209. package/src/ingestion/adapters/helpers.ts +309 -0
  210. package/src/ingestion/adapters/index.ts +15 -0
  211. package/src/ingestion/discovery.ts +130 -0
  212. package/src/ingestion/persist.ts +283 -0
  213. package/src/ingestion/scan.ts +247 -0
  214. package/src/ingestion/types.ts +78 -0
  215. package/src/lib/analytics.ts +592 -0
  216. package/src/lib/cost.ts +62 -0
  217. package/src/lib/csv.ts +15 -0
  218. package/src/lib/format.ts +51 -0
  219. package/src/lib/ids.ts +23 -0
  220. package/src/lib/pricing.ts +86 -0
  221. package/src/lib/token-estimator.ts +24 -0
  222. package/src/lib/utils.ts +6 -0
  223. package/tailwind.config.ts +53 -0
  224. package/tsconfig.json +28 -0
@@ -0,0 +1,178 @@
1
+ import { FileCandidate, NormalizedInteraction, NormalizedSession } from "../types";
2
+ import {
3
+ asObject,
4
+ extractModel,
5
+ extractRole,
6
+ extractText,
7
+ extractToolCalls,
8
+ extractUsage,
9
+ firstString,
10
+ normalizeInteraction,
11
+ parseTimestamp,
12
+ sessionNameFromFile
13
+ } from "./helpers";
14
+
15
+ type BuildOptions = {
16
+ file: FileCandidate;
17
+ records: Record<string, unknown>[];
18
+ provider: NormalizedSession["provider"];
19
+ tool: NormalizedSession["tool"];
20
+ storeRawMessageContent: boolean;
21
+ defaultSessionId?: string;
22
+ defaultProjectPath?: string | null;
23
+ };
24
+
25
+ function interactionExternalId(record: Record<string, unknown>, index: number) {
26
+ const message = asObject(record.message);
27
+ const payload = asObject(record.payload);
28
+ const response = asObject(record.response) ?? asObject(payload?.response);
29
+ return (
30
+ firstString(
31
+ record.uuid,
32
+ record.id,
33
+ record.message_id,
34
+ record.messageId,
35
+ message?.id,
36
+ payload?.id,
37
+ response?.id
38
+ ) ?? `${index}`
39
+ );
40
+ }
41
+
42
+ function sessionExternalId(record: Record<string, unknown>, fallback: string) {
43
+ const message = asObject(record.message);
44
+ const payload = asObject(record.payload);
45
+ return (
46
+ firstString(
47
+ record.session_id,
48
+ record.sessionId,
49
+ record.conversation_id,
50
+ record.conversationId,
51
+ record.thread_id,
52
+ record.threadId,
53
+ message?.session_id,
54
+ message?.sessionId,
55
+ payload?.session_id,
56
+ payload?.sessionId,
57
+ payload?.conversation_id
58
+ ) ?? fallback
59
+ );
60
+ }
61
+
62
+ function projectPathFromRecord(record: Record<string, unknown>) {
63
+ const payload = asObject(record.payload);
64
+ return firstString(
65
+ record.cwd,
66
+ record.project_path,
67
+ record.projectPath,
68
+ record.repository,
69
+ payload?.cwd,
70
+ payload?.project_path,
71
+ payload?.projectPath
72
+ );
73
+ }
74
+
75
+ function titleFromRecord(record: Record<string, unknown>) {
76
+ const payload = asObject(record.payload);
77
+ return firstString(record.title, record.summary, payload?.title, payload?.summary);
78
+ }
79
+
80
+ function hasUsage(usage: ReturnType<typeof extractUsage>) {
81
+ return Object.values(usage).some((value) => typeof value === "number" && value > 0);
82
+ }
83
+
84
+ function shouldKeepInteraction(record: Record<string, unknown>) {
85
+ const usage = extractUsage(record);
86
+ return Boolean(
87
+ extractText(record) ||
88
+ hasUsage(usage) ||
89
+ extractModel(record) ||
90
+ extractToolCalls(record).length ||
91
+ extractRole(record) !== "unknown"
92
+ );
93
+ }
94
+
95
+ function tuneRole(interaction: NormalizedInteraction): NormalizedInteraction {
96
+ if (interaction.role !== "unknown") return interaction;
97
+ if ((interaction.outputTokens ?? 0) > 0 || (interaction.reasoningTokens ?? 0) > 0) {
98
+ return { ...interaction, role: "assistant" };
99
+ }
100
+ if ((interaction.inputTokens ?? 0) > 0) {
101
+ return { ...interaction, role: "user" };
102
+ }
103
+ return interaction;
104
+ }
105
+
106
+ export function buildSessionsFromRecords(options: BuildOptions): NormalizedSession[] {
107
+ const fallbackSessionId = options.defaultSessionId ?? sessionNameFromFile(options.file.path);
108
+ const grouped = new Map<
109
+ string,
110
+ {
111
+ interactions: NormalizedInteraction[];
112
+ projectPath: string | null;
113
+ title: string | null;
114
+ timestamps: Date[];
115
+ metadata: Record<string, unknown>[];
116
+ }
117
+ >();
118
+
119
+ options.records.forEach((record, index) => {
120
+ const sessionId = sessionExternalId(record, fallbackSessionId);
121
+ const group =
122
+ grouped.get(sessionId) ??
123
+ {
124
+ interactions: [],
125
+ projectPath: options.defaultProjectPath ?? null,
126
+ title: null,
127
+ timestamps: [],
128
+ metadata: []
129
+ };
130
+
131
+ group.projectPath = group.projectPath ?? projectPathFromRecord(record);
132
+ group.title = group.title ?? titleFromRecord(record);
133
+ group.metadata.push(record);
134
+
135
+ const timestamp = parseTimestamp(
136
+ record.timestamp,
137
+ record.created_at,
138
+ record.createdAt,
139
+ record.time,
140
+ record.ts
141
+ );
142
+ if (timestamp) group.timestamps.push(timestamp);
143
+
144
+ if (shouldKeepInteraction(record)) {
145
+ const interaction = tuneRole(
146
+ normalizeInteraction(
147
+ record,
148
+ interactionExternalId(record, index),
149
+ options.storeRawMessageContent
150
+ )
151
+ );
152
+ group.interactions.push(interaction);
153
+ }
154
+
155
+ grouped.set(sessionId, group);
156
+ });
157
+
158
+ return Array.from(grouped.entries())
159
+ .filter(([, group]) => group.interactions.length > 0)
160
+ .map(([externalId, group]) => {
161
+ const sorted = [...group.timestamps].sort((a, b) => a.getTime() - b.getTime());
162
+ return {
163
+ externalId,
164
+ provider: options.provider,
165
+ tool: options.tool,
166
+ projectPath: group.projectPath,
167
+ projectName: group.projectPath ? undefined : "Unknown project",
168
+ startedAt: sorted[0] ?? options.file.modifiedTime,
169
+ endedAt: sorted[sorted.length - 1] ?? options.file.modifiedTime,
170
+ title: group.title ?? sessionNameFromFile(options.file.path),
171
+ sourceFile: options.file.path,
172
+ rawMetadata: {
173
+ parserInputRecords: group.metadata.length
174
+ },
175
+ interactions: group.interactions
176
+ };
177
+ });
178
+ }
@@ -0,0 +1,309 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { NormalizedInteraction, NormalizedToolCall } from "../types";
4
+ import { previewText } from "@/src/lib/token-estimator";
5
+
6
+ export async function readTextSample(filePath: string, bytes = 64_000) {
7
+ const handle = await fs.open(filePath, "r");
8
+ try {
9
+ const buffer = Buffer.alloc(bytes);
10
+ const { bytesRead } = await handle.read(buffer, 0, bytes, 0);
11
+ return buffer.subarray(0, bytesRead).toString("utf8");
12
+ } finally {
13
+ await handle.close();
14
+ }
15
+ }
16
+
17
+ export async function readFileText(filePath: string, maxBytes = 25 * 1024 * 1024) {
18
+ const stat = await fs.stat(filePath);
19
+ if (stat.size > maxBytes) {
20
+ throw new Error(`File is larger than ${Math.round(maxBytes / 1024 / 1024)} MB.`);
21
+ }
22
+ return fs.readFile(filePath, "utf8");
23
+ }
24
+
25
+ export function safeJsonParse(value: string): unknown | null {
26
+ try {
27
+ return JSON.parse(value);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function asObject(value: unknown): Record<string, unknown> | null {
34
+ return value && typeof value === "object" && !Array.isArray(value)
35
+ ? (value as Record<string, unknown>)
36
+ : null;
37
+ }
38
+
39
+ export function asArray(value: unknown): unknown[] {
40
+ return Array.isArray(value) ? value : [];
41
+ }
42
+
43
+ export function firstString(...values: unknown[]) {
44
+ for (const value of values) {
45
+ if (typeof value === "string" && value.trim()) return value.trim();
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function firstNumber(...values: unknown[]) {
51
+ for (const value of values) {
52
+ if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.round(value));
53
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
54
+ return Math.max(0, Math.round(Number(value)));
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function parseTimestamp(...values: unknown[]): Date | null {
61
+ for (const value of values) {
62
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
63
+ if (typeof value === "number" && Number.isFinite(value)) {
64
+ const ms = value > 10_000_000_000 ? value : value * 1000;
65
+ const date = new Date(ms);
66
+ if (!Number.isNaN(date.getTime())) return date;
67
+ }
68
+ if (typeof value === "string" && value.trim()) {
69
+ const date = new Date(value);
70
+ if (!Number.isNaN(date.getTime())) return date;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function textFromContent(value: unknown): string | null {
77
+ if (typeof value === "string") return value;
78
+ if (Array.isArray(value)) {
79
+ return value
80
+ .map((item) => {
81
+ if (typeof item === "string") return item;
82
+ const object = asObject(item);
83
+ return firstString(object?.text, object?.content, object?.input, object?.output);
84
+ })
85
+ .filter(Boolean)
86
+ .join("\n");
87
+ }
88
+ const object = asObject(value);
89
+ if (!object) return null;
90
+ return firstString(object.text, object.content, object.value, object.input, object.output);
91
+ }
92
+
93
+ export function extractText(record: Record<string, unknown>): string | null {
94
+ const message = asObject(record.message);
95
+ const payload = asObject(record.payload);
96
+ const response = asObject(record.response);
97
+ return (
98
+ textFromContent(record.text) ??
99
+ textFromContent(record.content) ??
100
+ textFromContent(record.prompt) ??
101
+ textFromContent(record.completion) ??
102
+ textFromContent(record.output) ??
103
+ textFromContent(message?.content) ??
104
+ textFromContent(payload?.content) ??
105
+ textFromContent(payload?.message) ??
106
+ textFromContent(response?.content) ??
107
+ null
108
+ );
109
+ }
110
+
111
+ export function extractUsage(record: Record<string, unknown>) {
112
+ const message = asObject(record.message);
113
+ const payload = asObject(record.payload);
114
+ const response = asObject(record.response) ?? asObject(payload?.response);
115
+ const usage =
116
+ asObject(record.usage) ??
117
+ asObject(message?.usage) ??
118
+ asObject(payload?.usage) ??
119
+ asObject(response?.usage) ??
120
+ asObject(record.token_usage) ??
121
+ asObject(record.tokens);
122
+ const inputDetails =
123
+ asObject(usage?.input_tokens_details) ??
124
+ asObject(usage?.prompt_tokens_details) ??
125
+ asObject(usage?.cache);
126
+ const outputDetails = asObject(usage?.output_tokens_details) ?? asObject(usage?.completion_tokens_details);
127
+
128
+ return {
129
+ inputTokens: firstNumber(
130
+ usage?.input_tokens,
131
+ usage?.prompt_tokens,
132
+ usage?.inputTokens,
133
+ usage?.promptTokens,
134
+ record.input_tokens,
135
+ record.prompt_tokens
136
+ ),
137
+ outputTokens: firstNumber(
138
+ usage?.output_tokens,
139
+ usage?.completion_tokens,
140
+ usage?.outputTokens,
141
+ usage?.completionTokens,
142
+ record.output_tokens,
143
+ record.completion_tokens
144
+ ),
145
+ cacheReadTokens: firstNumber(
146
+ usage?.cache_read_input_tokens,
147
+ usage?.cached_input_tokens,
148
+ inputDetails?.cached_tokens,
149
+ inputDetails?.cache_read_tokens,
150
+ record.cache_read_tokens
151
+ ),
152
+ cacheWriteTokens: firstNumber(
153
+ usage?.cache_creation_input_tokens,
154
+ usage?.cache_write_input_tokens,
155
+ inputDetails?.cache_creation_tokens,
156
+ inputDetails?.cache_write_tokens,
157
+ record.cache_write_tokens
158
+ ),
159
+ reasoningTokens: firstNumber(
160
+ usage?.reasoning_tokens,
161
+ outputDetails?.reasoning_tokens,
162
+ record.reasoning_tokens
163
+ ),
164
+ totalTokens: firstNumber(
165
+ usage?.total_tokens,
166
+ usage?.totalTokens,
167
+ usage?.tokens,
168
+ record.total_tokens,
169
+ record.totalTokens
170
+ )
171
+ };
172
+ }
173
+
174
+ export function extractModel(record: Record<string, unknown>) {
175
+ const message = asObject(record.message);
176
+ const payload = asObject(record.payload);
177
+ const response = asObject(record.response) ?? asObject(payload?.response);
178
+ return firstString(
179
+ record.model,
180
+ record.model_name,
181
+ record.modelName,
182
+ message?.model,
183
+ payload?.model,
184
+ response?.model
185
+ );
186
+ }
187
+
188
+ export function extractRole(record: Record<string, unknown>): NormalizedInteraction["role"] {
189
+ const message = asObject(record.message);
190
+ const raw = firstString(record.role, record.type, message?.role);
191
+ if (!raw) return "unknown";
192
+ const normalized = raw.toLowerCase();
193
+ if (normalized.includes("assistant") || normalized.includes("completion")) return "assistant";
194
+ if (normalized.includes("user") || normalized.includes("prompt")) return "user";
195
+ if (normalized.includes("system")) return "system";
196
+ if (normalized.includes("tool")) return "tool";
197
+ return "unknown";
198
+ }
199
+
200
+ export function extractToolCalls(record: Record<string, unknown>): NormalizedToolCall[] {
201
+ const message = asObject(record.message);
202
+ const payload = asObject(record.payload);
203
+ const possible = [
204
+ ...asArray(record.tool_calls),
205
+ ...asArray(record.toolCalls),
206
+ ...asArray(message?.tool_calls),
207
+ ...asArray(payload?.tool_calls),
208
+ ...asArray(payload?.toolCalls)
209
+ ];
210
+
211
+ const calls: NormalizedToolCall[] = [];
212
+ possible.forEach((item, index) => {
213
+ const object = asObject(item);
214
+ if (!object) return;
215
+ const name = firstString(object.name, object.tool, object.function_name, asObject(object.function)?.name);
216
+ if (!name) return;
217
+ calls.push({
218
+ externalId: firstString(object.id, object.call_id) ?? `${index}`,
219
+ name,
220
+ status: firstString(object.status, object.state),
221
+ durationMs: firstNumber(object.duration_ms, object.durationMs),
222
+ rawMetadata: object
223
+ });
224
+ });
225
+ return calls;
226
+ }
227
+
228
+ const sensitiveMetadataKeys = new Set([
229
+ "content",
230
+ "text",
231
+ "prompt",
232
+ "completion",
233
+ "input",
234
+ "output",
235
+ "arguments",
236
+ "message"
237
+ ]);
238
+
239
+ export function sanitizeMetadata(value: unknown, depth = 0): unknown {
240
+ if (value == null) return value;
241
+ if (typeof value === "string") return previewText(value, 160);
242
+ if (typeof value !== "object") return value;
243
+ if (depth > 3) return "[nested metadata]";
244
+ if (Array.isArray(value)) {
245
+ return value.slice(0, 5).map((item) => sanitizeMetadata(item, depth + 1));
246
+ }
247
+
248
+ const object = value as Record<string, unknown>;
249
+ return Object.fromEntries(
250
+ Object.entries(object).map(([key, item]) => {
251
+ if (sensitiveMetadataKeys.has(key.toLowerCase())) {
252
+ return [key, "[redacted: raw storage disabled]"];
253
+ }
254
+ return [key, sanitizeMetadata(item, depth + 1)];
255
+ })
256
+ );
257
+ }
258
+
259
+ export function normalizeInteraction(
260
+ record: Record<string, unknown>,
261
+ externalId: string,
262
+ storeRawMessageContent: boolean
263
+ ): NormalizedInteraction {
264
+ const text = extractText(record);
265
+ const usage = extractUsage(record);
266
+ const hasUsage = Object.values(usage).some((value) => value != null && value > 0);
267
+ const message = asObject(record.message);
268
+ const payload = asObject(record.payload);
269
+
270
+ return {
271
+ externalId,
272
+ timestamp: parseTimestamp(record.timestamp, record.created_at, record.createdAt, record.time, record.ts),
273
+ role: extractRole(record),
274
+ modelName: extractModel(record),
275
+ inputTokens: usage.inputTokens,
276
+ outputTokens: usage.outputTokens,
277
+ cacheReadTokens: usage.cacheReadTokens,
278
+ cacheWriteTokens: usage.cacheWriteTokens,
279
+ reasoningTokens: usage.reasoningTokens,
280
+ totalTokens: usage.totalTokens,
281
+ estimatedTokens: false,
282
+ tokenConfidence: hasUsage ? "exact" : text ? "high-confidence estimate" : "unknown",
283
+ latencyMs: firstNumber(record.latency_ms, record.latencyMs, payload?.latency_ms, message?.latency_ms),
284
+ rawText: storeRawMessageContent ? text : null,
285
+ rawTextPreview: previewText(text),
286
+ rawMetadata: storeRawMessageContent
287
+ ? record
288
+ : (sanitizeMetadata(record) as Record<string, unknown>),
289
+ toolCalls: extractToolCalls(record).map((toolCall) => ({
290
+ ...toolCall,
291
+ rawMetadata: storeRawMessageContent
292
+ ? toolCall.rawMetadata
293
+ : (sanitizeMetadata(toolCall.rawMetadata) as Record<string, unknown>)
294
+ }))
295
+ };
296
+ }
297
+
298
+ export function sessionNameFromFile(filePath: string) {
299
+ return path.basename(filePath).replace(/\.(jsonl|json|log|txt|md)$/i, "");
300
+ }
301
+
302
+ export function fileLooksLikeJsonl(sample: string) {
303
+ const lines = sample
304
+ .split(/\r?\n/)
305
+ .map((line) => line.trim())
306
+ .filter(Boolean)
307
+ .slice(0, 5);
308
+ return lines.length > 0 && lines.every((line) => safeJsonParse(line) !== null);
309
+ }
@@ -0,0 +1,15 @@
1
+ import { claudeCodeAdapter } from "./claude-code";
2
+ import { codexCliAdapter } from "./codex-cli";
3
+ import { genericJsonAdapter } from "./generic-json";
4
+ import { genericJsonlAdapter } from "./generic-jsonl";
5
+ import { genericLogAdapter } from "./generic-log";
6
+
7
+ export const adapters = [
8
+ claudeCodeAdapter,
9
+ codexCliAdapter,
10
+ genericJsonlAdapter,
11
+ genericJsonAdapter,
12
+ genericLogAdapter
13
+ ];
14
+
15
+ export type AdapterId = (typeof adapters)[number]["id"];
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs/promises";
2
+ import type { Dirent } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { FileCandidate } from "./types";
6
+
7
+ const supportedExtensions = new Set([
8
+ ".jsonl",
9
+ ".json",
10
+ ".log",
11
+ ".txt",
12
+ ".md",
13
+ ".db",
14
+ ".sqlite",
15
+ ".sqlite3"
16
+ ]);
17
+
18
+ const skippedDirectories = new Set([
19
+ "node_modules",
20
+ ".git",
21
+ ".next",
22
+ "dist",
23
+ "out",
24
+ "build",
25
+ "coverage",
26
+ "Library",
27
+ "Applications"
28
+ ]);
29
+
30
+ export function expandHome(input: string) {
31
+ if (input === "~") return os.homedir();
32
+ if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
33
+ return input;
34
+ }
35
+
36
+ async function exists(target: string) {
37
+ try {
38
+ await fs.access(target);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export async function getDefaultSearchRoots(customFolders: string[] = []) {
46
+ const home = os.homedir();
47
+ const workingDirectory = process.env.TOKENTRACE_WORKDIR ?? process.cwd();
48
+ const appDataDir = process.env.TOKENTRACE_APP_DATA_DIR;
49
+ const candidates = [
50
+ path.join(home, ".claude"),
51
+ path.join(home, ".config", "claude"),
52
+ path.join(home, ".codex"),
53
+ path.join(home, ".config", "codex"),
54
+ path.join(home, ".openai"),
55
+ path.join(workingDirectory, ".claude"),
56
+ path.join(workingDirectory, ".codex"),
57
+ path.join(workingDirectory, ".openai"),
58
+ path.join(workingDirectory, ".ai"),
59
+ ...(appDataDir ? [path.join(appDataDir, "wrapper-runs")] : []),
60
+ ...customFolders.map(expandHome)
61
+ ];
62
+
63
+ const unique = Array.from(new Set(candidates.map((candidate) => path.resolve(candidate))));
64
+ const present: string[] = [];
65
+ for (const candidate of unique) {
66
+ if (await exists(candidate)) present.push(candidate);
67
+ }
68
+ return present;
69
+ }
70
+
71
+ async function walkDirectory(root: string, depth: number, maxDepth: number, results: FileCandidate[]) {
72
+ if (depth > maxDepth) return;
73
+ let entries: Dirent[];
74
+ try {
75
+ entries = await fs.readdir(root, { withFileTypes: true });
76
+ } catch {
77
+ return;
78
+ }
79
+
80
+ for (const entry of entries) {
81
+ const fullPath = path.join(root, entry.name);
82
+ if (entry.isDirectory()) {
83
+ if (skippedDirectories.has(entry.name)) continue;
84
+ await walkDirectory(fullPath, depth + 1, maxDepth, results);
85
+ continue;
86
+ }
87
+
88
+ if (!entry.isFile()) continue;
89
+ const extension = path.extname(entry.name).toLowerCase();
90
+ if (!supportedExtensions.has(extension)) continue;
91
+
92
+ try {
93
+ const stat = await fs.stat(fullPath);
94
+ if (stat.size <= 0 || stat.size > 25 * 1024 * 1024) continue;
95
+ results.push({
96
+ path: fullPath,
97
+ modifiedTime: stat.mtime,
98
+ sizeBytes: stat.size
99
+ });
100
+ } catch {
101
+ continue;
102
+ }
103
+ }
104
+ }
105
+
106
+ export async function discoverFiles(roots: string[]) {
107
+ const results: FileCandidate[] = [];
108
+ for (const root of roots) {
109
+ const stat = await fs.stat(root).catch(() => null);
110
+ if (!stat) continue;
111
+ if (stat.isFile()) {
112
+ const extension = path.extname(root).toLowerCase();
113
+ if (supportedExtensions.has(extension)) {
114
+ results.push({
115
+ path: root,
116
+ modifiedTime: stat.mtime,
117
+ sizeBytes: stat.size
118
+ });
119
+ }
120
+ continue;
121
+ }
122
+ if (stat.isDirectory()) {
123
+ await walkDirectory(root, 0, 12, results);
124
+ }
125
+ }
126
+
127
+ const deduped = new Map<string, FileCandidate>();
128
+ for (const result of results) deduped.set(result.path, result);
129
+ return Array.from(deduped.values()).sort((a, b) => a.path.localeCompare(b.path));
130
+ }