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,283 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { sqlite } from "@/src/db/client";
4
+ import { calculateInteractionCost } from "@/src/lib/cost";
5
+ import { stableId, slugify } from "@/src/lib/ids";
6
+ import { estimateTokensFromText, previewText } from "@/src/lib/token-estimator";
7
+ import { NormalizedInteraction, NormalizedSession } from "./types";
8
+
9
+ export type ImportSessionResult = {
10
+ sessionsImported: number;
11
+ interactionsImported: number;
12
+ toolCallsImported: number;
13
+ warnings: string[];
14
+ };
15
+
16
+ function json(value: unknown) {
17
+ return JSON.stringify(value ?? null);
18
+ }
19
+
20
+ function insertIgnore(sql: string, values: unknown[]) {
21
+ return sqlite.prepare(sql).run(...values).changes;
22
+ }
23
+
24
+ function upsertProvider(session: NormalizedSession) {
25
+ insertIgnore(
26
+ "INSERT OR IGNORE INTO providers (id, name, type) VALUES (?, ?, ?)",
27
+ [session.provider.id, session.provider.name, session.provider.type]
28
+ );
29
+ }
30
+
31
+ function upsertTool(session: NormalizedSession) {
32
+ insertIgnore(
33
+ "INSERT OR IGNORE INTO tools (id, provider_id, name) VALUES (?, ?, ?)",
34
+ [session.tool.id, session.provider.id, session.tool.name]
35
+ );
36
+ }
37
+
38
+ function providerForTool(toolId: string) {
39
+ return sqlite.prepare("SELECT provider_id FROM tools WHERE id = ?").get(toolId) as
40
+ | { provider_id: string }
41
+ | undefined;
42
+ }
43
+
44
+ function getModel(providerId: string, modelName: string) {
45
+ return sqlite
46
+ .prepare(
47
+ `SELECT id, input_token_price, output_token_price, cached_input_token_price, currency
48
+ FROM models WHERE provider_id = ? AND lower(name) = lower(?)`
49
+ )
50
+ .get(providerId, modelName) as
51
+ | {
52
+ id: string;
53
+ input_token_price: number | null;
54
+ output_token_price: number | null;
55
+ cached_input_token_price: number | null;
56
+ currency: string;
57
+ }
58
+ | undefined;
59
+ }
60
+
61
+ function ensureModel(providerId: string, modelName: string | null | undefined) {
62
+ const name = modelName?.trim() || "unknown";
63
+ const existing = getModel(providerId, name);
64
+ if (existing) return existing;
65
+
66
+ const id = stableId("model", [providerId, name]);
67
+ insertIgnore(
68
+ `INSERT OR IGNORE INTO models
69
+ (id, provider_id, name, input_token_price, output_token_price, cached_input_token_price, currency, raw_metadata)
70
+ VALUES (?, ?, ?, NULL, NULL, NULL, 'USD', ?)`,
71
+ [
72
+ id,
73
+ providerId,
74
+ name,
75
+ json({
76
+ note: "Observed during import. Add prices on the Pricing page to enable cost calculation."
77
+ })
78
+ ]
79
+ );
80
+ return getModel(providerId, name) ?? {
81
+ id,
82
+ input_token_price: null,
83
+ output_token_price: null,
84
+ cached_input_token_price: null,
85
+ currency: "USD"
86
+ };
87
+ }
88
+
89
+ function findProjectRoot(startFile: string) {
90
+ let current = fs.statSync(startFile).isDirectory() ? startFile : path.dirname(startFile);
91
+ const home = process.env.HOME ?? path.parse(current).root;
92
+
93
+ while (current !== path.dirname(current)) {
94
+ if (
95
+ fs.existsSync(path.join(current, ".git")) ||
96
+ fs.existsSync(path.join(current, "package.json")) ||
97
+ fs.existsSync(path.join(current, "pyproject.toml")) ||
98
+ fs.existsSync(path.join(current, "Cargo.toml"))
99
+ ) {
100
+ return current;
101
+ }
102
+ if (current === home) break;
103
+ current = path.dirname(current);
104
+ }
105
+
106
+ return path.dirname(startFile);
107
+ }
108
+
109
+ function ensureProject(session: NormalizedSession) {
110
+ const projectPath = session.projectPath || findProjectRoot(session.sourceFile);
111
+ const resolved = path.resolve(projectPath);
112
+ const name = session.projectName || path.basename(resolved) || "Unknown project";
113
+ const id = stableId("project", [resolved]);
114
+ insertIgnore("INSERT OR IGNORE INTO projects (id, name, path) VALUES (?, ?, ?)", [
115
+ id,
116
+ name,
117
+ resolved
118
+ ]);
119
+ return id;
120
+ }
121
+
122
+ function normalizeTokens(interaction: NormalizedInteraction) {
123
+ const providedAnyToken = [
124
+ interaction.inputTokens,
125
+ interaction.outputTokens,
126
+ interaction.cacheReadTokens,
127
+ interaction.cacheWriteTokens,
128
+ interaction.reasoningTokens,
129
+ interaction.totalTokens
130
+ ].some((value) => value != null && value > 0);
131
+ const baseText = interaction.rawText || interaction.rawTextPreview || "";
132
+ const estimate = estimateTokensFromText(baseText).tokens;
133
+ const estimatedTokens = Boolean(interaction.estimatedTokens || !providedAnyToken);
134
+ let tokenConfidence = interaction.tokenConfidence ?? "unknown";
135
+
136
+ let inputTokens = interaction.inputTokens ?? 0;
137
+ let outputTokens = interaction.outputTokens ?? 0;
138
+ const cacheReadTokens = interaction.cacheReadTokens ?? 0;
139
+ const cacheWriteTokens = interaction.cacheWriteTokens ?? 0;
140
+ const reasoningTokens = interaction.reasoningTokens ?? 0;
141
+
142
+ if (!providedAnyToken && estimate > 0) {
143
+ if (interaction.role === "assistant") outputTokens = estimate;
144
+ else inputTokens = estimate;
145
+ if (tokenConfidence === "unknown") tokenConfidence = "low-confidence estimate";
146
+ }
147
+
148
+ const summed = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens;
149
+ const totalTokens = Math.max(interaction.totalTokens ?? 0, summed);
150
+
151
+ return {
152
+ inputTokens,
153
+ outputTokens,
154
+ cacheReadTokens,
155
+ cacheWriteTokens,
156
+ reasoningTokens,
157
+ totalTokens,
158
+ estimatedTokens,
159
+ tokenConfidence
160
+ };
161
+ }
162
+
163
+ export function importSessions(sessions: NormalizedSession[]): ImportSessionResult {
164
+ const warnings: string[] = [];
165
+ let sessionsImported = 0;
166
+ let interactionsImported = 0;
167
+ let toolCallsImported = 0;
168
+
169
+ const transaction = sqlite.transaction((records: NormalizedSession[]) => {
170
+ for (const session of records) {
171
+ upsertProvider(session);
172
+ upsertTool(session);
173
+ const providerId = providerForTool(session.tool.id)?.provider_id ?? session.provider.id;
174
+ const projectId = ensureProject(session);
175
+ const sessionSourceId = stableId("session-source", [
176
+ session.tool.id,
177
+ session.sourceFile,
178
+ session.externalId ?? session.title
179
+ ]);
180
+ const sessionId = stableId("session", [sessionSourceId]);
181
+ const insertedSession = insertIgnore(
182
+ `INSERT OR IGNORE INTO sessions
183
+ (id, source_id, tool_id, project_id, started_at, ended_at, title, source_file, raw_metadata)
184
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
185
+ [
186
+ sessionId,
187
+ sessionSourceId,
188
+ session.tool.id,
189
+ projectId,
190
+ session.startedAt?.getTime() ?? null,
191
+ session.endedAt?.getTime() ?? null,
192
+ session.title ?? null,
193
+ session.sourceFile,
194
+ json(session.rawMetadata)
195
+ ]
196
+ );
197
+ sessionsImported += insertedSession;
198
+
199
+ for (const interaction of session.interactions) {
200
+ const model = ensureModel(providerId, interaction.modelName);
201
+ const tokens = normalizeTokens(interaction);
202
+ const cost = calculateInteractionCost(tokens, {
203
+ inputTokenPrice: model.input_token_price,
204
+ outputTokenPrice: model.output_token_price,
205
+ cachedInputTokenPrice: model.cached_input_token_price,
206
+ currency: model.currency
207
+ });
208
+ const interactionSourceId = stableId("interaction-source", [
209
+ sessionSourceId,
210
+ interaction.externalId,
211
+ interaction.timestamp?.getTime(),
212
+ interaction.role,
213
+ interaction.rawTextPreview
214
+ ]);
215
+ const interactionId = stableId("interaction", [interactionSourceId]);
216
+ const insertedInteraction = insertIgnore(
217
+ `INSERT OR IGNORE INTO interactions
218
+ (id, source_id, session_id, timestamp, role, model_id, input_tokens, output_tokens,
219
+ cache_read_tokens, cache_write_tokens, reasoning_tokens, total_tokens, estimated_tokens,
220
+ token_confidence, cost, cost_estimated, latency_ms, raw_text_preview, raw_text, raw_metadata)
221
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
222
+ [
223
+ interactionId,
224
+ interactionSourceId,
225
+ sessionId,
226
+ interaction.timestamp?.getTime() ?? session.startedAt?.getTime() ?? null,
227
+ interaction.role,
228
+ model.id,
229
+ tokens.inputTokens,
230
+ tokens.outputTokens,
231
+ tokens.cacheReadTokens,
232
+ tokens.cacheWriteTokens,
233
+ tokens.reasoningTokens,
234
+ tokens.totalTokens,
235
+ tokens.estimatedTokens ? 1 : 0,
236
+ tokens.tokenConfidence,
237
+ cost.amount,
238
+ cost.status === "estimated" ? 1 : 0,
239
+ interaction.latencyMs ?? null,
240
+ previewText(interaction.rawTextPreview || interaction.rawText),
241
+ interaction.rawText ?? null,
242
+ json({
243
+ ...(interaction.rawMetadata ?? {}),
244
+ costStatus: cost.status,
245
+ costExplanation: cost.explanation
246
+ })
247
+ ]
248
+ );
249
+ interactionsImported += insertedInteraction;
250
+
251
+ for (const [index, toolCall] of (interaction.toolCalls ?? []).entries()) {
252
+ const toolCallId = stableId("toolcall", [
253
+ interactionId,
254
+ toolCall.externalId ?? index,
255
+ toolCall.name
256
+ ]);
257
+ toolCallsImported += insertIgnore(
258
+ `INSERT OR IGNORE INTO tool_calls
259
+ (id, interaction_id, name, status, duration_ms, raw_metadata)
260
+ VALUES (?, ?, ?, ?, ?, ?)`,
261
+ [
262
+ toolCallId,
263
+ interactionId,
264
+ toolCall.name,
265
+ toolCall.status ?? null,
266
+ toolCall.durationMs ?? null,
267
+ json(toolCall.rawMetadata)
268
+ ]
269
+ );
270
+ }
271
+ }
272
+ }
273
+ });
274
+
275
+ transaction(sessions);
276
+
277
+ return {
278
+ sessionsImported,
279
+ interactionsImported,
280
+ toolCallsImported,
281
+ warnings
282
+ };
283
+ }
@@ -0,0 +1,247 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { sqlite } from "@/src/db/client";
4
+ import { getAppSettings } from "@/src/db/settings";
5
+ import { hashContent, stableId } from "@/src/lib/ids";
6
+ import { adapters } from "./adapters";
7
+ import { discoverFiles, expandHome, getDefaultSearchRoots } from "./discovery";
8
+ import { importSessions } from "./persist";
9
+ import { FileCandidate, IngestionAdapter } from "./types";
10
+
11
+ export type RunScanOptions = {
12
+ folders?: string[];
13
+ force?: boolean;
14
+ includeDefaults?: boolean;
15
+ storeRawMessageContent?: boolean;
16
+ };
17
+
18
+ export type RunScanResult = {
19
+ scanRunId: string;
20
+ filesScanned: number;
21
+ recordsImported: number;
22
+ warnings: string[];
23
+ errors: string[];
24
+ };
25
+
26
+ function json(value: unknown) {
27
+ return JSON.stringify(value ?? null);
28
+ }
29
+
30
+ function hasImportedFile(file: FileCandidate) {
31
+ if (!file.hash) return false;
32
+ const row = sqlite
33
+ .prepare(
34
+ "SELECT id FROM scan_files WHERE path = ? AND file_hash = ? AND status = 'imported' LIMIT 1"
35
+ )
36
+ .get(file.path, file.hash);
37
+ return Boolean(row);
38
+ }
39
+
40
+ async function hashFile(file: FileCandidate): Promise<FileCandidate> {
41
+ const content = await fs.readFile(file.path);
42
+ return {
43
+ ...file,
44
+ hash: hashContent(content)
45
+ };
46
+ }
47
+
48
+ async function selectAdapter(file: FileCandidate) {
49
+ const matches: Array<{ adapter: IngestionAdapter; confidence: number; reason?: string }> = [];
50
+ const warnings: string[] = [];
51
+
52
+ for (const adapter of adapters) {
53
+ try {
54
+ const result = await adapter.detect(file);
55
+ if (result.detected) {
56
+ matches.push({
57
+ adapter,
58
+ confidence: result.confidence,
59
+ reason: result.reason
60
+ });
61
+ }
62
+ } catch (error) {
63
+ warnings.push(
64
+ `${adapter.displayName} detection failed: ${
65
+ error instanceof Error ? error.message : "Unknown error"
66
+ }`
67
+ );
68
+ }
69
+ }
70
+
71
+ matches.sort((a, b) => b.confidence - a.confidence);
72
+ return { selected: matches[0] ?? null, warnings };
73
+ }
74
+
75
+ function insertScanFile(args: {
76
+ scanRunId: string;
77
+ file: FileCandidate;
78
+ parser: string | null;
79
+ status: string;
80
+ recordsImported: number;
81
+ warnings: string[];
82
+ errors: string[];
83
+ rawMetadata?: Record<string, unknown>;
84
+ }) {
85
+ const id = stableId("scanfile", [
86
+ args.scanRunId,
87
+ args.file.path,
88
+ args.file.hash,
89
+ args.status,
90
+ args.parser
91
+ ]);
92
+ sqlite
93
+ .prepare(
94
+ `INSERT INTO scan_files
95
+ (id, scan_run_id, path, modified_time, size_bytes, file_hash, parser, status,
96
+ records_imported, warnings, errors, raw_metadata)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
98
+ )
99
+ .run(
100
+ id,
101
+ args.scanRunId,
102
+ args.file.path,
103
+ args.file.modifiedTime?.getTime() ?? null,
104
+ args.file.sizeBytes,
105
+ args.file.hash ?? null,
106
+ args.parser,
107
+ args.status,
108
+ args.recordsImported,
109
+ json(args.warnings),
110
+ json(args.errors),
111
+ json(args.rawMetadata ?? {})
112
+ );
113
+ }
114
+
115
+ export async function runScan(options: RunScanOptions = {}): Promise<RunScanResult> {
116
+ const settings = getAppSettings();
117
+ const explicitFolders = options.folders ?? [];
118
+ const roots =
119
+ options.includeDefaults === false
120
+ ? explicitFolders.map((folder) => path.resolve(expandHome(folder)))
121
+ : await getDefaultSearchRoots([...settings.customFolders, ...explicitFolders]);
122
+ const candidates = await discoverFiles(roots);
123
+ const startedAt = new Date();
124
+ const scanRunId = stableId("scan", [startedAt.getTime(), roots.join("|")]);
125
+ const allWarnings: string[] = [];
126
+ const allErrors: string[] = [];
127
+ let recordsImported = 0;
128
+ let filesScanned = 0;
129
+
130
+ sqlite
131
+ .prepare(
132
+ "INSERT INTO scan_runs (id, started_at, warnings, errors) VALUES (?, ?, '[]', '[]')"
133
+ )
134
+ .run(scanRunId, startedAt.getTime());
135
+
136
+ for (const candidate of candidates) {
137
+ filesScanned += 1;
138
+ let file = candidate;
139
+ const warnings: string[] = [];
140
+ const errors: string[] = [];
141
+
142
+ try {
143
+ file = await hashFile(candidate);
144
+ if (!options.force && hasImportedFile(file)) {
145
+ insertScanFile({
146
+ scanRunId,
147
+ file,
148
+ parser: null,
149
+ status: "skipped_duplicate",
150
+ recordsImported: 0,
151
+ warnings: ["File hash already imported. Use force rescan to parse again."],
152
+ errors: []
153
+ });
154
+ continue;
155
+ }
156
+
157
+ const adapterChoice = await selectAdapter(file);
158
+ warnings.push(...adapterChoice.warnings);
159
+
160
+ if (!adapterChoice.selected) {
161
+ insertScanFile({
162
+ scanRunId,
163
+ file,
164
+ parser: null,
165
+ status: "skipped_unknown",
166
+ recordsImported: 0,
167
+ warnings,
168
+ errors: ["No parser detected a compatible format."]
169
+ });
170
+ continue;
171
+ }
172
+
173
+ const parseResult = await adapterChoice.selected.adapter.parse(file, {
174
+ storeRawMessageContent:
175
+ options.storeRawMessageContent ?? settings.storeRawMessageContent
176
+ });
177
+ warnings.push(...parseResult.warnings);
178
+ errors.push(...parseResult.errors);
179
+ const importResult = importSessions(parseResult.sessions);
180
+ const tokenConfidence = parseResult.sessions
181
+ .flatMap((session) => session.interactions)
182
+ .reduce<Record<string, number>>((summary, interaction) => {
183
+ const key = interaction.tokenConfidence ?? "unknown";
184
+ summary[key] = (summary[key] ?? 0) + 1;
185
+ return summary;
186
+ }, {});
187
+ warnings.push(...importResult.warnings);
188
+ recordsImported += importResult.interactionsImported;
189
+
190
+ insertScanFile({
191
+ scanRunId,
192
+ file,
193
+ parser: adapterChoice.selected.adapter.id,
194
+ status: errors.length ? "imported_with_errors" : "imported",
195
+ recordsImported: importResult.interactionsImported,
196
+ warnings,
197
+ errors,
198
+ rawMetadata: {
199
+ confidence: adapterChoice.selected.confidence,
200
+ reason: adapterChoice.selected.reason,
201
+ tokenConfidence,
202
+ sessionsParsed: parseResult.sessions.length,
203
+ sessionsImported: importResult.sessionsImported,
204
+ toolCallsImported: importResult.toolCallsImported
205
+ }
206
+ });
207
+ } catch (error) {
208
+ const message = error instanceof Error ? error.message : "Unknown scan error";
209
+ errors.push(message);
210
+ insertScanFile({
211
+ scanRunId,
212
+ file,
213
+ parser: null,
214
+ status: "failed",
215
+ recordsImported: 0,
216
+ warnings,
217
+ errors
218
+ });
219
+ }
220
+
221
+ allWarnings.push(...warnings.map((warning) => `${candidate.path}: ${warning}`));
222
+ allErrors.push(...errors.map((error) => `${candidate.path}: ${error}`));
223
+ }
224
+
225
+ sqlite
226
+ .prepare(
227
+ `UPDATE scan_runs
228
+ SET completed_at = ?, files_scanned = ?, records_imported = ?, warnings = ?, errors = ?
229
+ WHERE id = ?`
230
+ )
231
+ .run(
232
+ Date.now(),
233
+ filesScanned,
234
+ recordsImported,
235
+ json(allWarnings),
236
+ json(allErrors),
237
+ scanRunId
238
+ );
239
+
240
+ return {
241
+ scanRunId,
242
+ filesScanned,
243
+ recordsImported,
244
+ warnings: allWarnings,
245
+ errors: allErrors
246
+ };
247
+ }
@@ -0,0 +1,78 @@
1
+ export type FileCandidate = {
2
+ path: string;
3
+ modifiedTime: Date | null;
4
+ sizeBytes: number;
5
+ hash?: string;
6
+ };
7
+
8
+ export type DetectionResult = {
9
+ detected: boolean;
10
+ confidence: number;
11
+ reason?: string;
12
+ };
13
+
14
+ export type NormalizedToolCall = {
15
+ externalId?: string;
16
+ name: string;
17
+ status?: string | null;
18
+ durationMs?: number | null;
19
+ rawMetadata?: Record<string, unknown>;
20
+ };
21
+
22
+ export type NormalizedInteraction = {
23
+ externalId?: string;
24
+ timestamp?: Date | null;
25
+ role: "user" | "assistant" | "system" | "tool" | "unknown";
26
+ modelName?: string | null;
27
+ inputTokens?: number | null;
28
+ outputTokens?: number | null;
29
+ cacheReadTokens?: number | null;
30
+ cacheWriteTokens?: number | null;
31
+ reasoningTokens?: number | null;
32
+ totalTokens?: number | null;
33
+ estimatedTokens?: boolean;
34
+ tokenConfidence?: "exact" | "high-confidence estimate" | "low-confidence estimate" | "unknown";
35
+ latencyMs?: number | null;
36
+ rawText?: string | null;
37
+ rawTextPreview?: string | null;
38
+ rawMetadata?: Record<string, unknown>;
39
+ toolCalls?: NormalizedToolCall[];
40
+ };
41
+
42
+ export type NormalizedSession = {
43
+ externalId?: string;
44
+ provider: {
45
+ id: string;
46
+ name: string;
47
+ type: string;
48
+ };
49
+ tool: {
50
+ id: string;
51
+ name: string;
52
+ };
53
+ projectPath?: string | null;
54
+ projectName?: string | null;
55
+ startedAt?: Date | null;
56
+ endedAt?: Date | null;
57
+ title?: string | null;
58
+ sourceFile: string;
59
+ rawMetadata?: Record<string, unknown>;
60
+ interactions: NormalizedInteraction[];
61
+ };
62
+
63
+ export type AdapterParseResult = {
64
+ sessions: NormalizedSession[];
65
+ warnings: string[];
66
+ errors: string[];
67
+ };
68
+
69
+ export type ParseContext = {
70
+ storeRawMessageContent: boolean;
71
+ };
72
+
73
+ export interface IngestionAdapter {
74
+ id: string;
75
+ displayName: string;
76
+ detect(file: FileCandidate): Promise<DetectionResult>;
77
+ parse(file: FileCandidate, context: ParseContext): Promise<AdapterParseResult>;
78
+ }