@tracemarketplace/cli 0.0.13 → 0.0.17

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 (94) hide show
  1. package/dist/api-client.d.ts +9 -2
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +80 -15
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/api-client.test.d.ts +2 -0
  6. package/dist/api-client.test.d.ts.map +1 -0
  7. package/dist/api-client.test.js +34 -0
  8. package/dist/api-client.test.js.map +1 -0
  9. package/dist/cli.js +48 -18
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/auto-submit.d.ts +2 -1
  12. package/dist/commands/auto-submit.d.ts.map +1 -1
  13. package/dist/commands/auto-submit.js +43 -56
  14. package/dist/commands/auto-submit.js.map +1 -1
  15. package/dist/commands/daemon.d.ts +8 -1
  16. package/dist/commands/daemon.d.ts.map +1 -1
  17. package/dist/commands/daemon.js +184 -63
  18. package/dist/commands/daemon.js.map +1 -1
  19. package/dist/commands/history.d.ts +3 -1
  20. package/dist/commands/history.d.ts.map +1 -1
  21. package/dist/commands/history.js +8 -4
  22. package/dist/commands/history.js.map +1 -1
  23. package/dist/commands/login.d.ts +5 -1
  24. package/dist/commands/login.d.ts.map +1 -1
  25. package/dist/commands/login.js +25 -9
  26. package/dist/commands/login.js.map +1 -1
  27. package/dist/commands/register.d.ts +1 -0
  28. package/dist/commands/register.d.ts.map +1 -1
  29. package/dist/commands/register.js +4 -39
  30. package/dist/commands/register.js.map +1 -1
  31. package/dist/commands/remove-daemon.d.ts +6 -0
  32. package/dist/commands/remove-daemon.d.ts.map +1 -0
  33. package/dist/commands/remove-daemon.js +66 -0
  34. package/dist/commands/remove-daemon.js.map +1 -0
  35. package/dist/commands/remove-hook.d.ts +6 -0
  36. package/dist/commands/remove-hook.d.ts.map +1 -0
  37. package/dist/commands/remove-hook.js +174 -0
  38. package/dist/commands/remove-hook.js.map +1 -0
  39. package/dist/commands/setup-hook.d.ts +2 -0
  40. package/dist/commands/setup-hook.d.ts.map +1 -1
  41. package/dist/commands/setup-hook.js +85 -41
  42. package/dist/commands/setup-hook.js.map +1 -1
  43. package/dist/commands/status.d.ts +3 -1
  44. package/dist/commands/status.d.ts.map +1 -1
  45. package/dist/commands/status.js +8 -4
  46. package/dist/commands/status.js.map +1 -1
  47. package/dist/commands/submit.d.ts +1 -0
  48. package/dist/commands/submit.d.ts.map +1 -1
  49. package/dist/commands/submit.js +138 -83
  50. package/dist/commands/submit.js.map +1 -1
  51. package/dist/commands/whoami.d.ts +3 -1
  52. package/dist/commands/whoami.d.ts.map +1 -1
  53. package/dist/commands/whoami.js +8 -4
  54. package/dist/commands/whoami.js.map +1 -1
  55. package/dist/config.d.ts +38 -6
  56. package/dist/config.d.ts.map +1 -1
  57. package/dist/config.js +175 -17
  58. package/dist/config.js.map +1 -1
  59. package/dist/constants.d.ts +8 -0
  60. package/dist/constants.d.ts.map +1 -0
  61. package/dist/constants.js +16 -0
  62. package/dist/constants.js.map +1 -0
  63. package/dist/flush.d.ts +49 -0
  64. package/dist/flush.d.ts.map +1 -0
  65. package/dist/flush.js +405 -0
  66. package/dist/flush.js.map +1 -0
  67. package/dist/flush.test.d.ts +2 -0
  68. package/dist/flush.test.d.ts.map +1 -0
  69. package/dist/flush.test.js +330 -0
  70. package/dist/flush.test.js.map +1 -0
  71. package/dist/submitter.d.ts.map +1 -1
  72. package/dist/submitter.js +5 -2
  73. package/dist/submitter.js.map +1 -1
  74. package/package.json +8 -7
  75. package/src/api-client.test.ts +47 -0
  76. package/src/api-client.ts +100 -16
  77. package/src/cli.ts +55 -19
  78. package/src/commands/auto-submit.ts +80 -40
  79. package/src/commands/daemon.ts +243 -60
  80. package/src/commands/history.ts +9 -4
  81. package/src/commands/login.ts +37 -9
  82. package/src/commands/remove-daemon.ts +75 -0
  83. package/src/commands/remove-hook.ts +194 -0
  84. package/src/commands/setup-hook.ts +93 -43
  85. package/src/commands/status.ts +8 -4
  86. package/src/commands/submit.ts +191 -83
  87. package/src/commands/whoami.ts +8 -4
  88. package/src/config.ts +241 -21
  89. package/src/constants.ts +18 -0
  90. package/src/flush.test.ts +395 -0
  91. package/src/flush.ts +591 -0
  92. package/vitest.config.ts +8 -0
  93. package/src/commands/register.ts +0 -52
  94. package/src/submitter.ts +0 -110
package/src/flush.ts ADDED
@@ -0,0 +1,591 @@
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
+ } from "@tracemarketplace/shared";
11
+ import { ApiClient } from "./api-client.js";
12
+ import {
13
+ loadState,
14
+ migrateSessionUploadState,
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
+ const UNCONFIRMED_RESUBMIT_MS = 2 * 60 * 60 * 1000; // re-submit after 2hr with no confirmation
25
+ const INGEST_CONCURRENCY = 12;
26
+
27
+ export interface SessionSource {
28
+ tool: TrackedSessionTool;
29
+ locator: string;
30
+ label: string;
31
+ }
32
+
33
+ export interface PlannedUpload {
34
+ trace: NormalizedTrace;
35
+ nextState: SessionUploadState;
36
+ }
37
+
38
+ export interface SessionPlan {
39
+ observedState: SessionUploadState;
40
+ uploads: PlannedUpload[];
41
+ pending: boolean;
42
+ }
43
+
44
+ export interface SessionFlushResult {
45
+ source: SessionSource;
46
+ sessionKey: string | null;
47
+ uploadedChunks: number;
48
+ duplicateChunks: number;
49
+ pending: boolean;
50
+ payoutCents: number;
51
+ turnCount: number;
52
+ migratedLegacyState: boolean;
53
+ error?: string;
54
+ }
55
+
56
+ export interface FlushResult {
57
+ processedSessions: number;
58
+ uploadedChunks: number;
59
+ duplicateChunks: number;
60
+ pendingSessions: number;
61
+ payoutCents: number;
62
+ results: SessionFlushResult[];
63
+ }
64
+
65
+ interface ChunkUploadResult {
66
+ duplicate: boolean;
67
+ payoutCents: number;
68
+ traceId: string | null;
69
+ error?: string;
70
+ }
71
+
72
+ interface IngestResponse {
73
+ queued?: boolean;
74
+ duplicate?: boolean;
75
+ trace_id?: string;
76
+ }
77
+
78
+ export function collectIdleSessionSources(
79
+ sessions: Record<string, SessionUploadState>,
80
+ now = new Date()
81
+ ): SessionSource[] {
82
+ return Object.values(sessions)
83
+ .filter((session) => isSessionIdlePending(session, now))
84
+ .map((session) => ({
85
+ tool: session.sourceTool,
86
+ locator: session.locator,
87
+ label: `${session.sourceTool}:${session.sourceSessionId}`,
88
+ }));
89
+ }
90
+
91
+ interface ChunkExistsResponse {
92
+ exists: boolean;
93
+ trace_id?: string;
94
+ }
95
+
96
+ export async function verifyUnconfirmedChunks(
97
+ state: ReturnType<typeof loadState>,
98
+ client: ApiClient,
99
+ now: Date,
100
+ ): Promise<void> {
101
+ for (const [key, session] of Object.entries(state.sessions)) {
102
+ if (session.confirmedChunkIndex >= session.nextChunkIndex) continue;
103
+
104
+ // Check if timed out — reset to re-submit from last confirmed point
105
+ if (session.unconfirmedSince) {
106
+ const age = now.getTime() - Date.parse(session.unconfirmedSince);
107
+ if (age >= UNCONFIRMED_RESUBMIT_MS) {
108
+ state.sessions[key] = {
109
+ ...session,
110
+ nextChunkIndex: session.confirmedChunkIndex,
111
+ openChunkStartTurn: session.confirmedOpenChunkStartTurn,
112
+ unconfirmedSince: null,
113
+ };
114
+ continue;
115
+ }
116
+ }
117
+
118
+ // Check each unconfirmed chunk sequentially — stop at first missing one
119
+ for (let i = session.confirmedChunkIndex; i < session.nextChunkIndex; i++) {
120
+ try {
121
+ const params = new URLSearchParams({
122
+ source_tool: session.sourceTool,
123
+ source_session_id: session.sourceSessionId,
124
+ chunk_index: String(i),
125
+ });
126
+ const result = await client.get(`/api/v1/traces/exists?${params}`) as ChunkExistsResponse;
127
+ if (!result.exists) break;
128
+ state.sessions[key] = {
129
+ ...state.sessions[key]!,
130
+ confirmedChunkIndex: i + 1,
131
+ confirmedOpenChunkStartTurn: state.sessions[key]!.openChunkStartTurn,
132
+ unconfirmedSince: i + 1 >= session.nextChunkIndex ? null : state.sessions[key]!.unconfirmedSince,
133
+ };
134
+ } catch {
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ export async function flushTrackedSessions(
142
+ config: Config,
143
+ sources: SessionSource[],
144
+ opts: { includeIdleTracked?: boolean; now?: Date; prefetchedTraces?: Map<string, NormalizedTrace> } = {}
145
+ ): Promise<FlushResult> {
146
+ const now = opts.now ?? new Date();
147
+ const state = loadState(config.profile);
148
+ const client = new ApiClient(config.serverUrl, config.apiKey);
149
+
150
+ const allSources = dedupeSources([
151
+ ...sources,
152
+ ...(opts.includeIdleTracked ? collectIdleSessionSources(state.sessions, now) : []),
153
+ ]);
154
+
155
+ // Migrate any existing sessions that predate the confirmation fields
156
+ for (const [key, session] of Object.entries(state.sessions)) {
157
+ state.sessions[key] = migrateSessionUploadState(session);
158
+ }
159
+
160
+ // Verify previously queued chunks and handle 2hr re-submit timeout
161
+ await verifyUnconfirmedChunks(state, client, now);
162
+
163
+ // Process sessions concurrently — each session's chunks stay sequential internally
164
+ const results = await pLimit(
165
+ allSources.map((source) => () => processSessionSource(
166
+ source, state, config, client, now,
167
+ opts.prefetchedTraces?.get(`${source.tool}:${source.locator}`),
168
+ )),
169
+ INGEST_CONCURRENCY,
170
+ );
171
+
172
+ saveState(state, config.profile);
173
+
174
+ return {
175
+ processedSessions: results.length,
176
+ uploadedChunks: results.reduce((sum, result) => sum + result.uploadedChunks, 0),
177
+ duplicateChunks: results.reduce((sum, result) => sum + result.duplicateChunks, 0),
178
+ pendingSessions: results.filter((result) => result.pending).length,
179
+ payoutCents: results.reduce((sum, result) => sum + result.payoutCents, 0),
180
+ results,
181
+ };
182
+ }
183
+
184
+ export function planSessionUploads(
185
+ trace: NormalizedTrace,
186
+ cursor: SessionUploadState,
187
+ now = new Date()
188
+ ): SessionPlan {
189
+ const observedState = observeTrace(cursor, trace);
190
+
191
+ if (observedState.openChunkStartTurn >= trace.turn_count) {
192
+ return {
193
+ observedState,
194
+ uploads: [],
195
+ pending: false,
196
+ };
197
+ }
198
+
199
+ const suffixTrace = sliceTraceFromTurn(trace, observedState.openChunkStartTurn);
200
+ const relativeChunks = chunkTrace(suffixTrace);
201
+ const uploads: PlannedUpload[] = [];
202
+ const nowIso = now.toISOString();
203
+ let nextState = observedState;
204
+
205
+ for (let i = 0; i < relativeChunks.length; i++) {
206
+ const relativeChunk = relativeChunks[i];
207
+ const isLast = i === relativeChunks.length - 1;
208
+ const outputTokens = relativeChunk.total_output_tokens ?? 0;
209
+ const closeReason = !isLast
210
+ ? "100k_tokens"
211
+ : outputTokens >= 100_000
212
+ ? "100k_tokens"
213
+ : isTraceIdle(trace, now)
214
+ ? "idle_2d"
215
+ : null;
216
+
217
+ if (!closeReason) {
218
+ break;
219
+ }
220
+
221
+ const finalizedChunk = finalizeChunk(relativeChunk, nextState, nowIso, closeReason);
222
+ nextState = applyUploadedChunk(nextState, trace, finalizedChunk);
223
+ uploads.push({ trace: finalizedChunk, nextState });
224
+ }
225
+
226
+ return {
227
+ observedState,
228
+ uploads,
229
+ pending: nextState.openChunkStartTurn < trace.turn_count,
230
+ };
231
+ }
232
+
233
+ export function createFreshSessionState(
234
+ source: SessionSource,
235
+ trace: NormalizedTrace
236
+ ): SessionUploadState {
237
+ return {
238
+ sourceTool: source.tool,
239
+ sourceSessionId: trace.source_session_id,
240
+ locator: source.locator,
241
+ nextChunkIndex: 0,
242
+ openChunkStartTurn: 0,
243
+ lastSeenTurnCount: 0,
244
+ lastActivityAt: null,
245
+ lastFlushedTurnId: null,
246
+ confirmedChunkIndex: 0,
247
+ confirmedOpenChunkStartTurn: 0,
248
+ unconfirmedSince: null,
249
+ };
250
+ }
251
+
252
+ export function migrateLegacySessionState(
253
+ source: SessionSource,
254
+ trace: NormalizedTrace,
255
+ legacyChunkIndex: number
256
+ ): SessionUploadState {
257
+ const nextChunkIndex = legacyChunkIndex + 1;
258
+ const openChunkStartTurn = trace.turn_count;
259
+ return {
260
+ sourceTool: source.tool,
261
+ sourceSessionId: trace.source_session_id,
262
+ locator: source.locator,
263
+ nextChunkIndex,
264
+ openChunkStartTurn,
265
+ lastSeenTurnCount: trace.turn_count,
266
+ lastActivityAt: getLastActivityAt(trace),
267
+ lastFlushedTurnId: trace.turns[trace.turn_count - 1]?.turn_id ?? null,
268
+ confirmedChunkIndex: nextChunkIndex,
269
+ confirmedOpenChunkStartTurn: openChunkStartTurn,
270
+ unconfirmedSince: null,
271
+ };
272
+ }
273
+
274
+ async function processSessionSource(
275
+ source: SessionSource,
276
+ state: ReturnType<typeof loadState>,
277
+ config: Config,
278
+ client: ApiClient,
279
+ now: Date,
280
+ prefetchedTrace?: NormalizedTrace,
281
+ ): Promise<SessionFlushResult> {
282
+ let trace: NormalizedTrace;
283
+
284
+ try {
285
+ trace = prefetchedTrace ?? await extractTraceFromSource(source, config.email);
286
+ } catch (err) {
287
+ return {
288
+ source,
289
+ sessionKey: null,
290
+ uploadedChunks: 0,
291
+ duplicateChunks: 0,
292
+ pending: false,
293
+ payoutCents: 0,
294
+ turnCount: 0,
295
+ migratedLegacyState: false,
296
+ error: `Extraction failed: ${err}`,
297
+ };
298
+ }
299
+
300
+ if (trace.turn_count === 0) {
301
+ return {
302
+ source,
303
+ sessionKey: stateKey(source.tool, trace.source_session_id),
304
+ uploadedChunks: 0,
305
+ duplicateChunks: 0,
306
+ pending: false,
307
+ payoutCents: 0,
308
+ turnCount: 0,
309
+ migratedLegacyState: false,
310
+ error: "Empty session",
311
+ };
312
+ }
313
+
314
+ const key = stateKey(trace.source_tool, trace.source_session_id);
315
+ const existing = state.sessions[key];
316
+ const legacyChunkIndex = state.chunks[key];
317
+ const migratedLegacyState = !existing && typeof legacyChunkIndex === "number";
318
+ const cursor = existing
319
+ ? { ...existing, locator: source.locator, sourceTool: trace.source_tool, sourceSessionId: trace.source_session_id }
320
+ : migratedLegacyState
321
+ ? migrateLegacySessionState(source, trace, legacyChunkIndex)
322
+ : createFreshSessionState(source, trace);
323
+
324
+ const plan = planSessionUploads(trace, cursor, now);
325
+ let workingState = plan.observedState;
326
+ let uploadedChunks = 0;
327
+ let duplicateChunks = 0;
328
+ let payoutCents = 0;
329
+
330
+ for (const upload of plan.uploads) {
331
+ const tUpload = Date.now();
332
+ const result = await uploadTraceChunk(upload.trace, client);
333
+ console.error(`[flush] ${key} chunk${upload.trace.chunk_index} done in ${Date.now()-tUpload}ms err=${result.error?.slice(0,60) ?? 'none'}`);
334
+ if (result.error) {
335
+ state.sessions[key] = workingState;
336
+ if (workingState.nextChunkIndex > 0) {
337
+ state.chunks[key] = workingState.nextChunkIndex - 1;
338
+ }
339
+ return {
340
+ source,
341
+ sessionKey: key,
342
+ uploadedChunks,
343
+ duplicateChunks,
344
+ pending: workingState.openChunkStartTurn < workingState.lastSeenTurnCount,
345
+ payoutCents,
346
+ turnCount: trace.turn_count,
347
+ migratedLegacyState,
348
+ error: result.error,
349
+ };
350
+ }
351
+
352
+ workingState = upload.nextState;
353
+ payoutCents += result.payoutCents;
354
+ if (result.duplicate) {
355
+ duplicateChunks++;
356
+ } else {
357
+ uploadedChunks++;
358
+ }
359
+ }
360
+
361
+ state.sessions[key] = workingState;
362
+ if (workingState.nextChunkIndex > 0) {
363
+ state.chunks[key] = workingState.nextChunkIndex - 1;
364
+ }
365
+
366
+ return {
367
+ source,
368
+ sessionKey: key,
369
+ uploadedChunks,
370
+ duplicateChunks,
371
+ pending: workingState.openChunkStartTurn < workingState.lastSeenTurnCount,
372
+ payoutCents,
373
+ turnCount: trace.turn_count,
374
+ migratedLegacyState,
375
+ };
376
+ }
377
+
378
+ async function extractTraceFromSource(
379
+ source: SessionSource,
380
+ email: string
381
+ ): Promise<NormalizedTrace> {
382
+ switch (source.tool) {
383
+ case "claude_code":
384
+ return extractClaudeCode(source.locator, email);
385
+ case "codex_cli": {
386
+ const buf = await readFile(source.locator);
387
+ return extractCodex(buf, email);
388
+ }
389
+ case "cursor":
390
+ return extractCursor(CURSOR_DB_PATH, source.locator, email);
391
+ }
392
+ }
393
+
394
+ async function uploadTraceChunk(
395
+ trace: NormalizedTrace,
396
+ client: ApiClient
397
+ ): Promise<ChunkUploadResult> {
398
+ // Client-side regex redaction runs before transmission; Presidio runs server-side async.
399
+ const payloadTrace = redactTrace(trace, { homeDir: homedir() });
400
+
401
+ try {
402
+ const result = await client.post("/api/v1/traces/ingest", {
403
+ trace: payloadTrace,
404
+ source_tool: payloadTrace.source_tool,
405
+ }) as IngestResponse;
406
+
407
+ if (result.duplicate) {
408
+ return { duplicate: true, payoutCents: 0, traceId: result.trace_id ?? null };
409
+ }
410
+
411
+ // 202 queued — payout credited asynchronously; show $0 until processed
412
+ return { duplicate: false, payoutCents: 0, traceId: null };
413
+ } catch (err) {
414
+ return {
415
+ duplicate: false,
416
+ payoutCents: 0,
417
+ traceId: null,
418
+ error: formatSubmitFailure(err),
419
+ };
420
+ }
421
+ }
422
+
423
+ function finalizeChunk(
424
+ trace: NormalizedTrace,
425
+ cursor: SessionUploadState,
426
+ nowIso: string,
427
+ closeReason: "100k_tokens" | "idle_2d"
428
+ ): NormalizedTrace {
429
+ return {
430
+ ...trace,
431
+ chunk_index: cursor.nextChunkIndex,
432
+ chunk_start_turn: cursor.openChunkStartTurn + (trace.chunk_start_turn ?? 0),
433
+ chunk_complete: true,
434
+ chunk_close_reason: closeReason,
435
+ chunk_closed_at: nowIso,
436
+ };
437
+ }
438
+
439
+ function applyUploadedChunk(
440
+ cursor: SessionUploadState,
441
+ trace: NormalizedTrace,
442
+ uploadedChunk: NormalizedTrace
443
+ ): SessionUploadState {
444
+ const nextOpenChunkStartTurn = (uploadedChunk.chunk_start_turn ?? cursor.openChunkStartTurn)
445
+ + (uploadedChunk.turn_count ?? uploadedChunk.turns.length);
446
+
447
+ const nowIso = new Date().toISOString();
448
+ return {
449
+ ...cursor,
450
+ nextChunkIndex: (uploadedChunk.chunk_index ?? cursor.nextChunkIndex) + 1,
451
+ openChunkStartTurn: nextOpenChunkStartTurn,
452
+ lastSeenTurnCount: trace.turn_count,
453
+ lastActivityAt: getLastActivityAt(trace),
454
+ lastFlushedTurnId: trace.turns[nextOpenChunkStartTurn - 1]?.turn_id ?? cursor.lastFlushedTurnId,
455
+ unconfirmedSince: cursor.unconfirmedSince ?? nowIso,
456
+ };
457
+ }
458
+
459
+ function observeTrace(
460
+ cursor: SessionUploadState,
461
+ trace: NormalizedTrace
462
+ ): SessionUploadState {
463
+ return {
464
+ ...cursor,
465
+ sourceTool: trace.source_tool,
466
+ sourceSessionId: trace.source_session_id,
467
+ openChunkStartTurn: Math.min(cursor.openChunkStartTurn, trace.turn_count),
468
+ lastSeenTurnCount: trace.turn_count,
469
+ lastActivityAt: getLastActivityAt(trace),
470
+ };
471
+ }
472
+
473
+ function sliceTraceFromTurn(
474
+ trace: NormalizedTrace,
475
+ startTurn: number
476
+ ): NormalizedTrace {
477
+ const turns = trace.turns.slice(startTurn);
478
+ const inputTokens = turns.reduce((sum, turn) => sum + (turn.usage?.input_tokens ?? 0), 0);
479
+ const outputTokens = turns.reduce((sum, turn) => sum + (turn.usage?.output_tokens ?? 0), 0);
480
+ const toolCallCount = turns.reduce(
481
+ (sum, turn) => sum + turn.content.filter((block) => block.type === "tool_use").length,
482
+ 0
483
+ );
484
+
485
+ return {
486
+ ...trace,
487
+ turns,
488
+ chunk_index: 0,
489
+ chunk_start_turn: 0,
490
+ chunk_complete: false,
491
+ chunk_close_reason: undefined,
492
+ chunk_closed_at: null,
493
+ turn_count: turns.length,
494
+ tool_call_count: toolCallCount,
495
+ has_tool_calls: toolCallCount > 0,
496
+ has_thinking_blocks: turns.some((turn) => turn.content.some((block) => block.type === "thinking")),
497
+ has_file_changes: turns.some((turn) =>
498
+ turn.content.some((block) =>
499
+ block.type === "tool_use"
500
+ && (block.tool_name === "Edit" || block.tool_name === "Write" || block.tool_name === "MultiEdit")
501
+ )
502
+ ),
503
+ has_shell_commands: turns.some((turn) =>
504
+ turn.content.some((block) => block.type === "tool_use" && block.tool_name === "Bash")
505
+ ),
506
+ total_input_tokens: inputTokens || null,
507
+ total_output_tokens: outputTokens || null,
508
+ started_at: turns.find((turn) => turn.timestamp)?.timestamp ?? trace.started_at,
509
+ ended_at: [...turns].reverse().find((turn) => turn.timestamp)?.timestamp ?? trace.ended_at,
510
+ };
511
+ }
512
+
513
+ function getLastActivityAt(trace: NormalizedTrace): string | null {
514
+ return trace.ended_at
515
+ ?? trace.turns[trace.turns.length - 1]?.timestamp
516
+ ?? trace.extracted_at
517
+ ?? null;
518
+ }
519
+
520
+ function isTraceIdle(trace: NormalizedTrace, now: Date): boolean {
521
+ const lastActivityAt = getLastActivityAt(trace);
522
+ if (!lastActivityAt) return false;
523
+
524
+ const lastActivityMs = Date.parse(lastActivityAt);
525
+ if (Number.isNaN(lastActivityMs)) return false;
526
+ return now.getTime() - lastActivityMs >= IDLE_FINALIZATION_MS;
527
+ }
528
+
529
+ function isSessionIdlePending(session: SessionUploadState, now: Date): boolean {
530
+ if (session.openChunkStartTurn >= session.lastSeenTurnCount) {
531
+ return false;
532
+ }
533
+ if (!session.lastActivityAt) {
534
+ return false;
535
+ }
536
+
537
+ const lastActivityMs = Date.parse(session.lastActivityAt);
538
+ if (Number.isNaN(lastActivityMs)) {
539
+ return false;
540
+ }
541
+
542
+ return now.getTime() - lastActivityMs >= IDLE_FINALIZATION_MS;
543
+ }
544
+
545
+ function dedupeSources(sources: SessionSource[]): SessionSource[] {
546
+ const unique = new Map<string, SessionSource>();
547
+
548
+ for (const source of sources) {
549
+ unique.set(`${source.tool}:${source.locator}`, source);
550
+ }
551
+
552
+ return [...unique.values()];
553
+ }
554
+
555
+ function formatSubmitFailure(err: unknown): string {
556
+ return err instanceof Error ? `Submit failed: ${err.message}` : `Submit failed: ${String(err)}`;
557
+ }
558
+
559
+ async function pLimit<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {
560
+ const results: T[] = new Array(tasks.length);
561
+ let index = 0;
562
+
563
+ async function worker() {
564
+ while (index < tasks.length) {
565
+ const i = index++;
566
+ results[i] = await tasks[i]!();
567
+ }
568
+ }
569
+
570
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
571
+ return results;
572
+ }
573
+
574
+ export function buildFileSessionSource(
575
+ tool: "claude_code" | "codex_cli",
576
+ filePath: string
577
+ ): SessionSource {
578
+ return {
579
+ tool,
580
+ locator: filePath,
581
+ label: filePath,
582
+ };
583
+ }
584
+
585
+ export function buildCursorSessionSource(sessionId: string): SessionSource {
586
+ return {
587
+ tool: "cursor",
588
+ locator: sessionId,
589
+ label: sessionId,
590
+ };
591
+ }
@@ -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
+ });
@@ -1,52 +0,0 @@
1
- import inquirer from "inquirer";
2
- import chalk from "chalk";
3
- import { saveConfig } from "../config.js";
4
-
5
- export async function registerCommand(opts: { serverUrl?: string }): Promise<void> {
6
- const { email } = await inquirer.prompt([
7
- {
8
- type: "input",
9
- name: "email",
10
- message: "Your email address:",
11
- validate: (v: string) => v.includes("@") || "Enter a valid email",
12
- },
13
- ]);
14
-
15
- const serverUrl =
16
- opts.serverUrl ??
17
- (
18
- await inquirer.prompt([
19
- {
20
- type: "input",
21
- name: "url",
22
- message: "Server URL:",
23
- default: "https://trace-marketplace-api.fly.dev",
24
- },
25
- ])
26
- ).url;
27
-
28
- const res = await fetch(`${serverUrl}/api/v1/register`, {
29
- method: "POST",
30
- headers: { "Content-Type": "application/json" },
31
- body: JSON.stringify({ email }),
32
- });
33
-
34
- const data = (await res.json()) as { api_key?: string; error?: string };
35
-
36
- if (!res.ok) {
37
- if (res.status === 409 && data.api_key) {
38
- console.log(chalk.yellow("Email already registered."));
39
- console.log(chalk.cyan("Your API key:"), chalk.bold(data.api_key));
40
- saveConfig({ apiKey: data.api_key, serverUrl, email });
41
- return;
42
- }
43
- throw new Error(data.error ?? `HTTP ${res.status}`);
44
- }
45
-
46
- const apiKey = data.api_key!;
47
- saveConfig({ apiKey, serverUrl, email });
48
-
49
- console.log(chalk.green("Registered successfully!"));
50
- console.log(chalk.cyan("Your API key:"), chalk.bold(apiKey));
51
- console.log(chalk.gray("Config saved to ~/.config/tracemarketplace/config.json"));
52
- }