@tracemarketplace/cli 0.0.11 → 0.0.15

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 (84) hide show
  1. package/dist/api-client.d.ts +2 -2
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +2 -2
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/cli.js +45 -14
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/auto-submit.d.ts +2 -1
  8. package/dist/commands/auto-submit.d.ts.map +1 -1
  9. package/dist/commands/auto-submit.js +43 -56
  10. package/dist/commands/auto-submit.js.map +1 -1
  11. package/dist/commands/daemon.d.ts +8 -1
  12. package/dist/commands/daemon.d.ts.map +1 -1
  13. package/dist/commands/daemon.js +118 -62
  14. package/dist/commands/daemon.js.map +1 -1
  15. package/dist/commands/history.d.ts +3 -1
  16. package/dist/commands/history.d.ts.map +1 -1
  17. package/dist/commands/history.js +8 -4
  18. package/dist/commands/history.js.map +1 -1
  19. package/dist/commands/login.d.ts +5 -1
  20. package/dist/commands/login.d.ts.map +1 -1
  21. package/dist/commands/login.js +25 -9
  22. package/dist/commands/login.js.map +1 -1
  23. package/dist/commands/register.d.ts +1 -0
  24. package/dist/commands/register.d.ts.map +1 -1
  25. package/dist/commands/register.js +4 -39
  26. package/dist/commands/register.js.map +1 -1
  27. package/dist/commands/remove-hook.d.ts +6 -0
  28. package/dist/commands/remove-hook.d.ts.map +1 -0
  29. package/dist/commands/remove-hook.js +174 -0
  30. package/dist/commands/remove-hook.js.map +1 -0
  31. package/dist/commands/setup-hook.d.ts +2 -0
  32. package/dist/commands/setup-hook.d.ts.map +1 -1
  33. package/dist/commands/setup-hook.js +86 -42
  34. package/dist/commands/setup-hook.js.map +1 -1
  35. package/dist/commands/status.d.ts +3 -1
  36. package/dist/commands/status.d.ts.map +1 -1
  37. package/dist/commands/status.js +8 -4
  38. package/dist/commands/status.js.map +1 -1
  39. package/dist/commands/submit.d.ts +1 -0
  40. package/dist/commands/submit.d.ts.map +1 -1
  41. package/dist/commands/submit.js +136 -83
  42. package/dist/commands/submit.js.map +1 -1
  43. package/dist/commands/whoami.d.ts +3 -1
  44. package/dist/commands/whoami.d.ts.map +1 -1
  45. package/dist/commands/whoami.js +8 -4
  46. package/dist/commands/whoami.js.map +1 -1
  47. package/dist/config.d.ts +33 -6
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +163 -17
  50. package/dist/config.js.map +1 -1
  51. package/dist/constants.d.ts +8 -0
  52. package/dist/constants.d.ts.map +1 -0
  53. package/dist/constants.js +16 -0
  54. package/dist/constants.js.map +1 -0
  55. package/dist/flush.d.ts +46 -0
  56. package/dist/flush.d.ts.map +1 -0
  57. package/dist/flush.js +338 -0
  58. package/dist/flush.js.map +1 -0
  59. package/dist/flush.test.d.ts +2 -0
  60. package/dist/flush.test.d.ts.map +1 -0
  61. package/dist/flush.test.js +175 -0
  62. package/dist/flush.test.js.map +1 -0
  63. package/dist/submitter.d.ts.map +1 -1
  64. package/dist/submitter.js +5 -2
  65. package/dist/submitter.js.map +1 -1
  66. package/package.json +8 -7
  67. package/src/api-client.ts +3 -3
  68. package/src/cli.ts +51 -14
  69. package/src/commands/auto-submit.ts +80 -40
  70. package/src/commands/daemon.ts +166 -59
  71. package/src/commands/history.ts +9 -4
  72. package/src/commands/login.ts +37 -9
  73. package/src/commands/register.ts +5 -49
  74. package/src/commands/remove-hook.ts +194 -0
  75. package/src/commands/setup-hook.ts +94 -44
  76. package/src/commands/status.ts +8 -4
  77. package/src/commands/submit.ts +189 -83
  78. package/src/commands/whoami.ts +8 -4
  79. package/src/config.ts +223 -21
  80. package/src/constants.ts +18 -0
  81. package/src/flush.test.ts +214 -0
  82. package/src/flush.ts +505 -0
  83. package/vitest.config.ts +8 -0
  84. package/src/submitter.ts +0 -110
package/src/flush.ts ADDED
@@ -0,0 +1,505 @@
1
+ import { readFile } from "fs/promises";
2
+ import { homedir } from "os";
3
+ import {
4
+ chunkTrace,
5
+ extractClaudeCode,
6
+ extractCodex,
7
+ extractCursor,
8
+ redactTrace,
9
+ type NormalizedTrace,
10
+ type Turn,
11
+ } from "@tracemarketplace/shared";
12
+ import { ApiClient } from "./api-client.js";
13
+ import {
14
+ loadState,
15
+ saveState,
16
+ stateKey,
17
+ type Config,
18
+ type SessionUploadState,
19
+ type TrackedSessionTool,
20
+ } from "./config.js";
21
+ import { CURSOR_DB_PATH } from "./sessions.js";
22
+
23
+ const IDLE_FINALIZATION_MS = 2 * 24 * 60 * 60 * 1000;
24
+
25
+ export interface SessionSource {
26
+ tool: TrackedSessionTool;
27
+ locator: string;
28
+ label: string;
29
+ }
30
+
31
+ export interface PlannedUpload {
32
+ trace: NormalizedTrace;
33
+ nextState: SessionUploadState;
34
+ }
35
+
36
+ export interface SessionPlan {
37
+ observedState: SessionUploadState;
38
+ uploads: PlannedUpload[];
39
+ pending: boolean;
40
+ }
41
+
42
+ export interface SessionFlushResult {
43
+ source: SessionSource;
44
+ sessionKey: string | null;
45
+ uploadedChunks: number;
46
+ duplicateChunks: number;
47
+ pending: boolean;
48
+ payoutCents: number;
49
+ turnCount: number;
50
+ migratedLegacyState: boolean;
51
+ error?: string;
52
+ }
53
+
54
+ export interface FlushResult {
55
+ processedSessions: number;
56
+ uploadedChunks: number;
57
+ duplicateChunks: number;
58
+ pendingSessions: number;
59
+ payoutCents: number;
60
+ results: SessionFlushResult[];
61
+ }
62
+
63
+ interface ChunkUploadResult {
64
+ duplicate: boolean;
65
+ payoutCents: number;
66
+ traceId: string | null;
67
+ error?: string;
68
+ }
69
+
70
+ export function collectIdleSessionSources(
71
+ sessions: Record<string, SessionUploadState>,
72
+ now = new Date()
73
+ ): SessionSource[] {
74
+ return Object.values(sessions)
75
+ .filter((session) => isSessionIdlePending(session, now))
76
+ .map((session) => ({
77
+ tool: session.sourceTool,
78
+ locator: session.locator,
79
+ label: `${session.sourceTool}:${session.sourceSessionId}`,
80
+ }));
81
+ }
82
+
83
+ export async function flushTrackedSessions(
84
+ config: Config,
85
+ sources: SessionSource[],
86
+ opts: { includeIdleTracked?: boolean; now?: Date } = {}
87
+ ): Promise<FlushResult> {
88
+ const now = opts.now ?? new Date();
89
+ const state = loadState(config.profile);
90
+ const client = new ApiClient(config.serverUrl, config.apiKey);
91
+
92
+ const allSources = dedupeSources([
93
+ ...sources,
94
+ ...(opts.includeIdleTracked ? collectIdleSessionSources(state.sessions, now) : []),
95
+ ]);
96
+
97
+ const results: SessionFlushResult[] = [];
98
+
99
+ for (const source of allSources) {
100
+ results.push(await processSessionSource(source, state, config, client, now));
101
+ }
102
+
103
+ saveState(state, config.profile);
104
+
105
+ return {
106
+ processedSessions: results.length,
107
+ uploadedChunks: results.reduce((sum, result) => sum + result.uploadedChunks, 0),
108
+ duplicateChunks: results.reduce((sum, result) => sum + result.duplicateChunks, 0),
109
+ pendingSessions: results.filter((result) => result.pending).length,
110
+ payoutCents: results.reduce((sum, result) => sum + result.payoutCents, 0),
111
+ results,
112
+ };
113
+ }
114
+
115
+ export function planSessionUploads(
116
+ trace: NormalizedTrace,
117
+ cursor: SessionUploadState,
118
+ now = new Date()
119
+ ): SessionPlan {
120
+ const observedState = observeTrace(cursor, trace);
121
+
122
+ if (observedState.openChunkStartTurn >= trace.turn_count) {
123
+ return {
124
+ observedState,
125
+ uploads: [],
126
+ pending: false,
127
+ };
128
+ }
129
+
130
+ const suffixTrace = sliceTraceFromTurn(trace, observedState.openChunkStartTurn);
131
+ const relativeChunks = chunkTrace(suffixTrace);
132
+ const uploads: PlannedUpload[] = [];
133
+ const nowIso = now.toISOString();
134
+ let nextState = observedState;
135
+
136
+ for (let i = 0; i < relativeChunks.length; i++) {
137
+ const relativeChunk = relativeChunks[i];
138
+ const isLast = i === relativeChunks.length - 1;
139
+ const outputTokens = relativeChunk.total_output_tokens ?? 0;
140
+ const closeReason = !isLast
141
+ ? "100k_tokens"
142
+ : outputTokens >= 100_000
143
+ ? "100k_tokens"
144
+ : isTraceIdle(trace, now)
145
+ ? "idle_2d"
146
+ : null;
147
+
148
+ if (!closeReason) {
149
+ break;
150
+ }
151
+
152
+ const finalizedChunk = finalizeChunk(relativeChunk, nextState, nowIso, closeReason);
153
+ nextState = applyUploadedChunk(nextState, trace, finalizedChunk);
154
+ uploads.push({ trace: finalizedChunk, nextState });
155
+ }
156
+
157
+ return {
158
+ observedState,
159
+ uploads,
160
+ pending: nextState.openChunkStartTurn < trace.turn_count,
161
+ };
162
+ }
163
+
164
+ export function createFreshSessionState(
165
+ source: SessionSource,
166
+ trace: NormalizedTrace
167
+ ): SessionUploadState {
168
+ return {
169
+ sourceTool: source.tool,
170
+ sourceSessionId: trace.source_session_id,
171
+ locator: source.locator,
172
+ nextChunkIndex: 0,
173
+ openChunkStartTurn: 0,
174
+ lastSeenTurnCount: 0,
175
+ lastActivityAt: null,
176
+ lastFlushedTurnId: null,
177
+ };
178
+ }
179
+
180
+ export function migrateLegacySessionState(
181
+ source: SessionSource,
182
+ trace: NormalizedTrace,
183
+ legacyChunkIndex: number
184
+ ): SessionUploadState {
185
+ return {
186
+ sourceTool: source.tool,
187
+ sourceSessionId: trace.source_session_id,
188
+ locator: source.locator,
189
+ nextChunkIndex: legacyChunkIndex + 1,
190
+ openChunkStartTurn: trace.turn_count,
191
+ lastSeenTurnCount: trace.turn_count,
192
+ lastActivityAt: getLastActivityAt(trace),
193
+ lastFlushedTurnId: trace.turns[trace.turn_count - 1]?.turn_id ?? null,
194
+ };
195
+ }
196
+
197
+ async function processSessionSource(
198
+ source: SessionSource,
199
+ state: ReturnType<typeof loadState>,
200
+ config: Config,
201
+ client: ApiClient,
202
+ now: Date
203
+ ): Promise<SessionFlushResult> {
204
+ let trace: NormalizedTrace;
205
+
206
+ try {
207
+ trace = await extractTraceFromSource(source, config.email);
208
+ } catch (err) {
209
+ return {
210
+ source,
211
+ sessionKey: null,
212
+ uploadedChunks: 0,
213
+ duplicateChunks: 0,
214
+ pending: false,
215
+ payoutCents: 0,
216
+ turnCount: 0,
217
+ migratedLegacyState: false,
218
+ error: `Extraction failed: ${err}`,
219
+ };
220
+ }
221
+
222
+ if (trace.turn_count === 0) {
223
+ return {
224
+ source,
225
+ sessionKey: stateKey(source.tool, trace.source_session_id),
226
+ uploadedChunks: 0,
227
+ duplicateChunks: 0,
228
+ pending: false,
229
+ payoutCents: 0,
230
+ turnCount: 0,
231
+ migratedLegacyState: false,
232
+ error: "Empty session",
233
+ };
234
+ }
235
+
236
+ const key = stateKey(trace.source_tool, trace.source_session_id);
237
+ const existing = state.sessions[key];
238
+ const legacyChunkIndex = state.chunks[key];
239
+ const migratedLegacyState = !existing && typeof legacyChunkIndex === "number";
240
+ const cursor = existing
241
+ ? { ...existing, locator: source.locator, sourceTool: trace.source_tool, sourceSessionId: trace.source_session_id }
242
+ : migratedLegacyState
243
+ ? migrateLegacySessionState(source, trace, legacyChunkIndex)
244
+ : createFreshSessionState(source, trace);
245
+
246
+ const plan = planSessionUploads(trace, cursor, now);
247
+ let workingState = plan.observedState;
248
+ let uploadedChunks = 0;
249
+ let duplicateChunks = 0;
250
+ let payoutCents = 0;
251
+
252
+ for (const upload of plan.uploads) {
253
+ const result = await uploadTraceChunk(upload.trace, client);
254
+ if (result.error) {
255
+ state.sessions[key] = workingState;
256
+ if (workingState.nextChunkIndex > 0) {
257
+ state.chunks[key] = workingState.nextChunkIndex - 1;
258
+ }
259
+ return {
260
+ source,
261
+ sessionKey: key,
262
+ uploadedChunks,
263
+ duplicateChunks,
264
+ pending: workingState.openChunkStartTurn < workingState.lastSeenTurnCount,
265
+ payoutCents,
266
+ turnCount: trace.turn_count,
267
+ migratedLegacyState,
268
+ error: result.error,
269
+ };
270
+ }
271
+
272
+ workingState = upload.nextState;
273
+ payoutCents += result.payoutCents;
274
+ if (result.duplicate) {
275
+ duplicateChunks++;
276
+ } else {
277
+ uploadedChunks++;
278
+ }
279
+ }
280
+
281
+ state.sessions[key] = workingState;
282
+ if (workingState.nextChunkIndex > 0) {
283
+ state.chunks[key] = workingState.nextChunkIndex - 1;
284
+ }
285
+
286
+ return {
287
+ source,
288
+ sessionKey: key,
289
+ uploadedChunks,
290
+ duplicateChunks,
291
+ pending: workingState.openChunkStartTurn < workingState.lastSeenTurnCount,
292
+ payoutCents,
293
+ turnCount: trace.turn_count,
294
+ migratedLegacyState,
295
+ };
296
+ }
297
+
298
+ async function extractTraceFromSource(
299
+ source: SessionSource,
300
+ email: string
301
+ ): Promise<NormalizedTrace> {
302
+ switch (source.tool) {
303
+ case "claude_code":
304
+ return extractClaudeCode(source.locator, email);
305
+ case "codex_cli": {
306
+ const buf = await readFile(source.locator);
307
+ return extractCodex(buf, email);
308
+ }
309
+ case "cursor":
310
+ return extractCursor(CURSOR_DB_PATH, source.locator, email);
311
+ }
312
+ }
313
+
314
+ async function uploadTraceChunk(
315
+ trace: NormalizedTrace,
316
+ client: ApiClient
317
+ ): Promise<ChunkUploadResult> {
318
+ try {
319
+ const result = await client.post("/api/v1/traces/batch", {
320
+ traces: [redactTrace(trace, { homeDir: homedir() })],
321
+ source_tool: trace.source_tool,
322
+ }) as {
323
+ accepted: number;
324
+ duplicate: number;
325
+ traces: Array<{
326
+ error?: string;
327
+ is_duplicate?: boolean;
328
+ payout_cents?: number;
329
+ trace_id?: string;
330
+ }>;
331
+ };
332
+
333
+ const traceResult = result.traces?.[0] ?? {};
334
+ if (traceResult.error) {
335
+ return {
336
+ duplicate: false,
337
+ payoutCents: 0,
338
+ traceId: traceResult.trace_id ?? null,
339
+ error: traceResult.error,
340
+ };
341
+ }
342
+
343
+ return {
344
+ duplicate: traceResult.is_duplicate === true || (result.duplicate > 0 && result.accepted === 0),
345
+ payoutCents: traceResult.payout_cents ?? 0,
346
+ traceId: traceResult.trace_id ?? null,
347
+ };
348
+ } catch (err) {
349
+ return {
350
+ duplicate: false,
351
+ payoutCents: 0,
352
+ traceId: null,
353
+ error: `Submit failed: ${err}`,
354
+ };
355
+ }
356
+ }
357
+
358
+ function finalizeChunk(
359
+ trace: NormalizedTrace,
360
+ cursor: SessionUploadState,
361
+ nowIso: string,
362
+ closeReason: "100k_tokens" | "idle_2d"
363
+ ): NormalizedTrace {
364
+ return {
365
+ ...trace,
366
+ chunk_index: cursor.nextChunkIndex,
367
+ chunk_start_turn: cursor.openChunkStartTurn + (trace.chunk_start_turn ?? 0),
368
+ chunk_complete: true,
369
+ chunk_close_reason: closeReason,
370
+ chunk_closed_at: nowIso,
371
+ };
372
+ }
373
+
374
+ function applyUploadedChunk(
375
+ cursor: SessionUploadState,
376
+ trace: NormalizedTrace,
377
+ uploadedChunk: NormalizedTrace
378
+ ): SessionUploadState {
379
+ const nextOpenChunkStartTurn = (uploadedChunk.chunk_start_turn ?? cursor.openChunkStartTurn)
380
+ + (uploadedChunk.turn_count ?? uploadedChunk.turns.length);
381
+
382
+ return {
383
+ ...cursor,
384
+ nextChunkIndex: (uploadedChunk.chunk_index ?? cursor.nextChunkIndex) + 1,
385
+ openChunkStartTurn: nextOpenChunkStartTurn,
386
+ lastSeenTurnCount: trace.turn_count,
387
+ lastActivityAt: getLastActivityAt(trace),
388
+ lastFlushedTurnId: trace.turns[nextOpenChunkStartTurn - 1]?.turn_id ?? cursor.lastFlushedTurnId,
389
+ };
390
+ }
391
+
392
+ function observeTrace(
393
+ cursor: SessionUploadState,
394
+ trace: NormalizedTrace
395
+ ): SessionUploadState {
396
+ return {
397
+ ...cursor,
398
+ sourceTool: trace.source_tool,
399
+ sourceSessionId: trace.source_session_id,
400
+ openChunkStartTurn: Math.min(cursor.openChunkStartTurn, trace.turn_count),
401
+ lastSeenTurnCount: trace.turn_count,
402
+ lastActivityAt: getLastActivityAt(trace),
403
+ };
404
+ }
405
+
406
+ function sliceTraceFromTurn(
407
+ trace: NormalizedTrace,
408
+ startTurn: number
409
+ ): NormalizedTrace {
410
+ const turns = trace.turns.slice(startTurn);
411
+ const inputTokens = turns.reduce((sum, turn) => sum + (turn.usage?.input_tokens ?? 0), 0);
412
+ const outputTokens = turns.reduce((sum, turn) => sum + (turn.usage?.output_tokens ?? 0), 0);
413
+ const toolCallCount = turns.reduce(
414
+ (sum, turn) => sum + turn.content.filter((block) => block.type === "tool_use").length,
415
+ 0
416
+ );
417
+
418
+ return {
419
+ ...trace,
420
+ turns,
421
+ chunk_index: 0,
422
+ chunk_start_turn: 0,
423
+ chunk_complete: false,
424
+ chunk_close_reason: undefined,
425
+ chunk_closed_at: null,
426
+ turn_count: turns.length,
427
+ tool_call_count: toolCallCount,
428
+ has_tool_calls: toolCallCount > 0,
429
+ has_thinking_blocks: turns.some((turn) => turn.content.some((block) => block.type === "thinking")),
430
+ has_file_changes: turns.some((turn) =>
431
+ turn.content.some((block) =>
432
+ block.type === "tool_use"
433
+ && (block.tool_name === "Edit" || block.tool_name === "Write" || block.tool_name === "MultiEdit")
434
+ )
435
+ ),
436
+ has_shell_commands: turns.some((turn) =>
437
+ turn.content.some((block) => block.type === "tool_use" && block.tool_name === "Bash")
438
+ ),
439
+ total_input_tokens: inputTokens || null,
440
+ total_output_tokens: outputTokens || null,
441
+ started_at: turns.find((turn) => turn.timestamp)?.timestamp ?? trace.started_at,
442
+ ended_at: [...turns].reverse().find((turn) => turn.timestamp)?.timestamp ?? trace.ended_at,
443
+ };
444
+ }
445
+
446
+ function getLastActivityAt(trace: NormalizedTrace): string | null {
447
+ return trace.ended_at
448
+ ?? trace.turns[trace.turns.length - 1]?.timestamp
449
+ ?? trace.extracted_at
450
+ ?? null;
451
+ }
452
+
453
+ function isTraceIdle(trace: NormalizedTrace, now: Date): boolean {
454
+ const lastActivityAt = getLastActivityAt(trace);
455
+ if (!lastActivityAt) return false;
456
+
457
+ const lastActivityMs = Date.parse(lastActivityAt);
458
+ if (Number.isNaN(lastActivityMs)) return false;
459
+ return now.getTime() - lastActivityMs >= IDLE_FINALIZATION_MS;
460
+ }
461
+
462
+ function isSessionIdlePending(session: SessionUploadState, now: Date): boolean {
463
+ if (session.openChunkStartTurn >= session.lastSeenTurnCount) {
464
+ return false;
465
+ }
466
+ if (!session.lastActivityAt) {
467
+ return false;
468
+ }
469
+
470
+ const lastActivityMs = Date.parse(session.lastActivityAt);
471
+ if (Number.isNaN(lastActivityMs)) {
472
+ return false;
473
+ }
474
+
475
+ return now.getTime() - lastActivityMs >= IDLE_FINALIZATION_MS;
476
+ }
477
+
478
+ function dedupeSources(sources: SessionSource[]): SessionSource[] {
479
+ const unique = new Map<string, SessionSource>();
480
+
481
+ for (const source of sources) {
482
+ unique.set(`${source.tool}:${source.locator}`, source);
483
+ }
484
+
485
+ return [...unique.values()];
486
+ }
487
+
488
+ export function buildFileSessionSource(
489
+ tool: "claude_code" | "codex_cli",
490
+ filePath: string
491
+ ): SessionSource {
492
+ return {
493
+ tool,
494
+ locator: filePath,
495
+ label: filePath,
496
+ };
497
+ }
498
+
499
+ export function buildCursorSessionSource(sessionId: string): SessionSource {
500
+ return {
501
+ tool: "cursor",
502
+ locator: sessionId,
503
+ label: sessionId,
504
+ };
505
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["src/**/*.test.ts"],
7
+ },
8
+ });
package/src/submitter.ts DELETED
@@ -1,110 +0,0 @@
1
- /**
2
- * submitter.ts — extract one session file and submit it to the API.
3
- * Shared by auto-submit (hook), submit (batch), and daemon (watch).
4
- */
5
- import { readFile } from "fs/promises";
6
- import { homedir } from "os";
7
- import { extractClaudeCode, extractCodex, extractCursor, redactTrace, chunkTrace, type NormalizedTrace } from "@tracemarketplace/shared";
8
- import { ApiClient } from "./api-client.js";
9
- import { CURSOR_DB_PATH } from "./sessions.js";
10
- import { loadState, saveState, stateKey } from "./config.js";
11
- import type { Config } from "./config.js";
12
-
13
- export interface SubmitResult {
14
- accepted: boolean;
15
- superseded: boolean;
16
- duplicate: boolean;
17
- turnCount: number;
18
- payoutCents: number;
19
- traceId: string | null;
20
- error?: string;
21
- }
22
-
23
- /**
24
- * Extract a claude_code or codex_cli session from a file path and submit it.
25
- */
26
- export async function submitFile(
27
- tool: "claude_code" | "codex_cli",
28
- filePath: string,
29
- config: Config
30
- ): Promise<SubmitResult> {
31
- let trace;
32
- try {
33
- if (tool === "claude_code") {
34
- trace = await extractClaudeCode(filePath, config.email);
35
- } else {
36
- const buf = await readFile(filePath);
37
- trace = await extractCodex(buf, config.email);
38
- }
39
- } catch (err) {
40
- return { accepted: false, superseded: false, duplicate: false, turnCount: 0, payoutCents: 0, traceId: null, error: `Extraction failed: ${err}` };
41
- }
42
-
43
- if (trace.turn_count === 0) {
44
- return { accepted: false, superseded: false, duplicate: false, turnCount: 0, payoutCents: 0, traceId: null, error: "Empty session" };
45
- }
46
-
47
- return submitTrace(trace, config);
48
- }
49
-
50
- /**
51
- * Extract a Cursor session by session ID and submit it.
52
- */
53
- export async function submitCursorSession(
54
- sessionId: string,
55
- config: Config
56
- ): Promise<SubmitResult> {
57
- let trace;
58
- try {
59
- trace = await extractCursor(CURSOR_DB_PATH, sessionId, config.email);
60
- } catch (err) {
61
- return { accepted: false, superseded: false, duplicate: false, turnCount: 0, payoutCents: 0, traceId: null, error: `Cursor extraction failed: ${err}` };
62
- }
63
- return submitTrace(trace, config);
64
- }
65
-
66
- async function submitTrace(trace: Awaited<ReturnType<typeof extractClaudeCode>>, config: Config): Promise<SubmitResult> {
67
- const state = loadState();
68
- const key = stateKey(trace.source_tool, trace.source_session_id);
69
- const lastSubmittedChunk = state.chunks[key] ?? -1;
70
-
71
- // Chunk the session and only send new chunks
72
- const chunks = chunkTrace(trace);
73
- const newChunks = chunks.filter((c) => (c.chunk_index ?? 0) > lastSubmittedChunk);
74
-
75
- if (newChunks.length === 0) {
76
- return { accepted: false, superseded: false, duplicate: true, turnCount: trace.turn_count, payoutCents: 0, traceId: null };
77
- }
78
-
79
- const home = homedir();
80
- const client = new ApiClient(config.serverUrl, config.apiKey);
81
-
82
- try {
83
- const result = await client.post("/api/v1/traces/batch", {
84
- traces: newChunks.map((c) => redactTrace(c, { homeDir: home })),
85
- source_tool: trace.source_tool,
86
- }) as {
87
- accepted: number;
88
- duplicate: number;
89
- traces: Array<{ trace_id?: string; payout_cents?: number }>;
90
- };
91
-
92
- // Persist the highest chunk index we successfully submitted
93
- const maxChunk = Math.max(...newChunks.map((c) => c.chunk_index ?? 0));
94
- state.chunks[key] = maxChunk;
95
- saveState(state);
96
-
97
- const totalPayout = result.traces?.reduce((s, t) => s + (t.payout_cents ?? 0), 0) ?? 0;
98
- const first = result.traces?.[0];
99
- return {
100
- accepted: result.accepted > 0,
101
- superseded: false,
102
- duplicate: result.duplicate > 0 && result.accepted === 0,
103
- turnCount: trace.turn_count,
104
- payoutCents: totalPayout,
105
- traceId: first?.trace_id ?? null,
106
- };
107
- } catch (err) {
108
- return { accepted: false, superseded: false, duplicate: false, turnCount: trace.turn_count, payoutCents: 0, traceId: null, error: `Submit failed: ${err}` };
109
- }
110
- }