@tracemarketplace/cli 0.0.13 → 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.
- package/dist/api-client.d.ts +2 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +2 -2
- package/dist/api-client.js.map +1 -1
- package/dist/cli.js +45 -14
- package/dist/cli.js.map +1 -1
- package/dist/commands/auto-submit.d.ts +2 -1
- package/dist/commands/auto-submit.d.ts.map +1 -1
- package/dist/commands/auto-submit.js +43 -56
- package/dist/commands/auto-submit.js.map +1 -1
- package/dist/commands/daemon.d.ts +8 -1
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +118 -62
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/history.d.ts +3 -1
- package/dist/commands/history.d.ts.map +1 -1
- package/dist/commands/history.js +8 -4
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/login.d.ts +5 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +25 -9
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/register.d.ts +1 -0
- package/dist/commands/register.d.ts.map +1 -1
- package/dist/commands/register.js +4 -39
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/remove-hook.d.ts +6 -0
- package/dist/commands/remove-hook.d.ts.map +1 -0
- package/dist/commands/remove-hook.js +174 -0
- package/dist/commands/remove-hook.js.map +1 -0
- package/dist/commands/setup-hook.d.ts +2 -0
- package/dist/commands/setup-hook.d.ts.map +1 -1
- package/dist/commands/setup-hook.js +85 -41
- package/dist/commands/setup-hook.js.map +1 -1
- package/dist/commands/status.d.ts +3 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +8 -4
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/submit.d.ts +1 -0
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +136 -83
- package/dist/commands/submit.js.map +1 -1
- package/dist/commands/whoami.d.ts +3 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +8 -4
- package/dist/commands/whoami.js.map +1 -1
- package/dist/config.d.ts +33 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +163 -17
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +16 -0
- package/dist/constants.js.map +1 -0
- package/dist/flush.d.ts +46 -0
- package/dist/flush.d.ts.map +1 -0
- package/dist/flush.js +338 -0
- package/dist/flush.js.map +1 -0
- package/dist/flush.test.d.ts +2 -0
- package/dist/flush.test.d.ts.map +1 -0
- package/dist/flush.test.js +175 -0
- package/dist/flush.test.js.map +1 -0
- package/dist/submitter.d.ts.map +1 -1
- package/dist/submitter.js +5 -2
- package/dist/submitter.js.map +1 -1
- package/package.json +8 -7
- package/src/api-client.ts +3 -3
- package/src/cli.ts +51 -14
- package/src/commands/auto-submit.ts +80 -40
- package/src/commands/daemon.ts +166 -59
- package/src/commands/history.ts +9 -4
- package/src/commands/login.ts +37 -9
- package/src/commands/register.ts +5 -49
- package/src/commands/remove-hook.ts +194 -0
- package/src/commands/setup-hook.ts +93 -43
- package/src/commands/status.ts +8 -4
- package/src/commands/submit.ts +189 -83
- package/src/commands/whoami.ts +8 -4
- package/src/config.ts +223 -21
- package/src/constants.ts +18 -0
- package/src/flush.test.ts +214 -0
- package/src/flush.ts +505 -0
- package/vitest.config.ts +8 -0
- 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
|
+
}
|
package/vitest.config.ts
ADDED
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
|
-
}
|