@tracemarketplace/cli 0.0.1

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 (72) hide show
  1. package/dist/api-client.d.ts +8 -0
  2. package/dist/api-client.d.ts.map +1 -0
  3. package/dist/api-client.js +34 -0
  4. package/dist/api-client.js.map +1 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +69 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/commands/auto-submit.d.ts +9 -0
  10. package/dist/commands/auto-submit.d.ts.map +1 -0
  11. package/dist/commands/auto-submit.js +111 -0
  12. package/dist/commands/auto-submit.js.map +1 -0
  13. package/dist/commands/daemon.d.ts +2 -0
  14. package/dist/commands/daemon.d.ts.map +1 -0
  15. package/dist/commands/daemon.js +125 -0
  16. package/dist/commands/daemon.js.map +1 -0
  17. package/dist/commands/history.d.ts +2 -0
  18. package/dist/commands/history.d.ts.map +1 -0
  19. package/dist/commands/history.js +32 -0
  20. package/dist/commands/history.js.map +1 -0
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.d.ts.map +1 -0
  23. package/dist/commands/login.js +69 -0
  24. package/dist/commands/login.js.map +1 -0
  25. package/dist/commands/register.d.ts +4 -0
  26. package/dist/commands/register.d.ts.map +1 -0
  27. package/dist/commands/register.js +43 -0
  28. package/dist/commands/register.js.map +1 -0
  29. package/dist/commands/setup-hook.d.ts +6 -0
  30. package/dist/commands/setup-hook.d.ts.map +1 -0
  31. package/dist/commands/setup-hook.js +148 -0
  32. package/dist/commands/setup-hook.js.map +1 -0
  33. package/dist/commands/status.d.ts +2 -0
  34. package/dist/commands/status.d.ts.map +1 -0
  35. package/dist/commands/status.js +23 -0
  36. package/dist/commands/status.js.map +1 -0
  37. package/dist/commands/submit.d.ts +8 -0
  38. package/dist/commands/submit.d.ts.map +1 -0
  39. package/dist/commands/submit.js +149 -0
  40. package/dist/commands/submit.js.map +1 -0
  41. package/dist/commands/whoami.d.ts +2 -0
  42. package/dist/commands/whoami.d.ts.map +1 -0
  43. package/dist/commands/whoami.js +16 -0
  44. package/dist/commands/whoami.js.map +1 -0
  45. package/dist/config.d.ts +9 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +23 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/sessions.d.ts +16 -0
  50. package/dist/sessions.d.ts.map +1 -0
  51. package/dist/sessions.js +107 -0
  52. package/dist/sessions.js.map +1 -0
  53. package/dist/submitter.d.ts +19 -0
  54. package/dist/submitter.d.ts.map +1 -0
  55. package/dist/submitter.js +65 -0
  56. package/dist/submitter.js.map +1 -0
  57. package/package.json +31 -0
  58. package/src/api-client.ts +33 -0
  59. package/src/cli.ts +82 -0
  60. package/src/commands/auto-submit.ts +95 -0
  61. package/src/commands/daemon.ts +128 -0
  62. package/src/commands/history.ts +50 -0
  63. package/src/commands/login.ts +75 -0
  64. package/src/commands/register.ts +52 -0
  65. package/src/commands/setup-hook.ts +175 -0
  66. package/src/commands/status.ts +26 -0
  67. package/src/commands/submit.ts +184 -0
  68. package/src/commands/whoami.ts +22 -0
  69. package/src/config.ts +29 -0
  70. package/src/sessions.ts +105 -0
  71. package/src/submitter.ts +89 -0
  72. package/tsconfig.json +8 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * setup-hook — installs trace auto-submit as a per-turn hook for AI coding tools.
3
+ * Run once; sessions are then captured automatically with no user action.
4
+ *
5
+ * Supported tools:
6
+ * --tool claude-code → writes ~/.claude/settings.json Stop hook (per-turn)
7
+ * --tool cursor → writes ~/.cursor/hooks.json stop hook (per-turn)
8
+ * --tool codex → writes ~/.codex/config.toml [[hooks]] after_agent (per-turn)
9
+ * (no flag) → installs for all detected tools
10
+ */
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
12
+ import { homedir } from "os";
13
+ import { join } from "path";
14
+ import chalk from "chalk";
15
+
16
+ interface SetupHookOptions {
17
+ tool?: string;
18
+ }
19
+
20
+ // The command that hooks invoke — must be on PATH after `npm install -g`
21
+ const HOOK_COMMAND = "trace auto-submit";
22
+
23
+ export async function setupHookCommand(opts: SetupHookOptions): Promise<void> {
24
+ const tools = opts.tool ? [opts.tool] : detectInstalledTools();
25
+
26
+ if (tools.length === 0) {
27
+ console.log(chalk.yellow("No supported AI coding tools detected."));
28
+ console.log(chalk.gray("Install Claude Code, Cursor, or Codex CLI, then run setup-hook again."));
29
+ console.log(chalk.gray("Or specify: trace setup-hook --tool claude-code"));
30
+ return;
31
+ }
32
+
33
+ for (const tool of tools) {
34
+ try {
35
+ switch (tool) {
36
+ case "claude-code":
37
+ setupClaudeCode();
38
+ break;
39
+ case "cursor":
40
+ setupCursor();
41
+ break;
42
+ case "codex":
43
+ setupCodex();
44
+ break;
45
+ default:
46
+ console.log(chalk.yellow(`Unknown tool: ${tool}. Supported: claude-code, cursor`));
47
+ }
48
+ } catch (err) {
49
+ console.error(chalk.red(`Failed to set up hook for ${tool}: ${err}`));
50
+ }
51
+ }
52
+
53
+ console.log(chalk.gray(`\nHook logs: ~/.config/tracemarketplace/auto-submit.log`));
54
+ console.log(chalk.gray(`Remove hooks: trace remove-hook`));
55
+ }
56
+
57
+ // ─── Claude Code ────────────────────────────────────────────────────────────
58
+ // Hook config: ~/.claude/settings.json
59
+ // Stop hook fires at session end with stdin: { session_id, transcript_path }
60
+
61
+ function setupClaudeCode() {
62
+ const settingsPath = join(homedir(), ".claude", "settings.json");
63
+ mkdirSync(join(homedir(), ".claude"), { recursive: true });
64
+
65
+ let settings: Record<string, unknown> = {};
66
+ if (existsSync(settingsPath)) {
67
+ try { settings = JSON.parse(readFileSync(settingsPath, "utf-8")); } catch {}
68
+ }
69
+
70
+ const hooks = (settings.hooks as Record<string, unknown> | undefined) ?? {};
71
+ const stopHooks = (hooks["Stop"] as unknown[] | undefined) ?? [];
72
+
73
+ // Don't add if already present
74
+ const alreadyInstalled = stopHooks.some((h: unknown) => {
75
+ const entry = h as Record<string, unknown>;
76
+ const innerHooks = entry.hooks as Array<Record<string, unknown>> | undefined;
77
+ return innerHooks?.some((ih) => String(ih.command ?? "").includes("trace auto-submit"));
78
+ });
79
+
80
+ if (!alreadyInstalled) {
81
+ stopHooks.push({
82
+ matcher: "",
83
+ hooks: [{ type: "command", command: HOOK_COMMAND }],
84
+ });
85
+ hooks["Stop"] = stopHooks;
86
+ settings.hooks = hooks;
87
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
88
+ }
89
+
90
+ console.log(chalk.green("✓ Claude Code") + chalk.gray(` — Stop hook installed in ${settingsPath}`));
91
+ }
92
+
93
+ // ─── Cursor ─────────────────────────────────────────────────────────────────
94
+ // Hook config: ~/.cursor/hooks.json
95
+ // stop fires after each assistant turn with stdin: { sessionId, terminationReason, duration }
96
+
97
+ function setupCursor() {
98
+ const hooksPath = join(homedir(), ".cursor", "hooks.json");
99
+ mkdirSync(join(homedir(), ".cursor"), { recursive: true });
100
+
101
+ let hooks: unknown[] = [];
102
+ if (existsSync(hooksPath)) {
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(hooksPath, "utf-8"));
105
+ hooks = Array.isArray(parsed) ? parsed : (parsed.hooks ?? []);
106
+ } catch {}
107
+ }
108
+
109
+ // Remove any old sessionEnd entry for trace auto-submit (migration)
110
+ hooks = hooks.filter((h: unknown) => {
111
+ const entry = h as Record<string, unknown>;
112
+ return !(entry.event === "sessionEnd" && String(entry.command ?? "").includes("trace auto-submit"));
113
+ });
114
+
115
+ const alreadyInstalled = hooks.some((h: unknown) => {
116
+ const entry = h as Record<string, unknown>;
117
+ return entry.event === "stop" && String(entry.command ?? "").includes("trace auto-submit");
118
+ });
119
+
120
+ if (!alreadyInstalled) {
121
+ hooks.push({ event: "stop", command: HOOK_COMMAND, timeout: 30 });
122
+ writeFileSync(hooksPath, JSON.stringify(hooks, null, 2), "utf-8");
123
+ }
124
+
125
+ console.log(chalk.green("✓ Cursor") + chalk.gray(` — stop hook installed in ${hooksPath}`));
126
+ }
127
+
128
+ // ─── Codex CLI ───────────────────────────────────────────────────────────────
129
+ // Hook config: ~/.codex/config.toml
130
+ // after_agent fires after each agent turn with stdin:
131
+ // { "thread-id": "...", "turn-id": "...", "cwd": "...", "last-assistant-message": "..." }
132
+ // Uses TOML [[hooks]] array-of-tables format.
133
+
134
+ const CODEX_HOOK_MARKER = "# trace-marketplace-hook";
135
+
136
+ function setupCodex() {
137
+ const configPath = join(homedir(), ".codex", "config.toml");
138
+ mkdirSync(join(homedir(), ".codex"), { recursive: true });
139
+
140
+ let existing = "";
141
+ if (existsSync(configPath)) {
142
+ try { existing = readFileSync(configPath, "utf-8"); } catch {}
143
+ }
144
+
145
+ if (existing.includes("trace auto-submit")) {
146
+ console.log(chalk.green("✓ Codex") + chalk.gray(` — after_agent hook already installed in ${configPath}`));
147
+ return;
148
+ }
149
+
150
+ const hookEntry = `\n${CODEX_HOOK_MARKER}\n[[hooks]]\nevent = "after_agent"\ncommand = "${HOOK_COMMAND} --tool codex"\n`;
151
+ writeFileSync(configPath, existing + hookEntry, "utf-8");
152
+
153
+ console.log(chalk.green("✓ Codex") + chalk.gray(` — after_agent hook installed in ${configPath}`));
154
+ }
155
+
156
+ // ─── Tool detection ──────────────────────────────────────────────────────────
157
+
158
+ function detectInstalledTools(): string[] {
159
+ const found: string[] = [];
160
+
161
+ if (existsSync(join(homedir(), ".claude"))) {
162
+ found.push("claude-code");
163
+ }
164
+ if (
165
+ existsSync(join(homedir(), "Library", "Application Support", "Cursor")) ||
166
+ existsSync(join(homedir(), ".cursor"))
167
+ ) {
168
+ found.push("cursor");
169
+ }
170
+ if (existsSync(join(homedir(), ".codex"))) {
171
+ found.push("codex");
172
+ }
173
+
174
+ return found;
175
+ }
@@ -0,0 +1,26 @@
1
+ import chalk from "chalk";
2
+ import { loadConfig } from "../config.js";
3
+ import { ApiClient } from "../api-client.js";
4
+
5
+ export async function statusCommand(): Promise<void> {
6
+ const config = loadConfig();
7
+ if (!config) {
8
+ console.error(chalk.red("Not registered. Run: trace register"));
9
+ process.exit(1);
10
+ }
11
+
12
+ const client = new ApiClient(config.serverUrl, config.apiKey);
13
+ const [me, subs] = await Promise.all([
14
+ client.get("/api/v1/me") as Promise<{ email: string; balanceCents?: number; balance_cents?: number }>,
15
+ client.get("/api/v1/submissions") as Promise<any[]>,
16
+ ]);
17
+
18
+ const balance = ((me as any).balanceCents ?? (me as any).balance_cents ?? 0) / 100;
19
+ console.log(chalk.bold("Balance:"), chalk.green(`$${balance.toFixed(2)}`));
20
+ console.log(chalk.bold("Submissions:"), (subs as any[]).length);
21
+
22
+ const pending = (subs as any[]).filter((s) => s.acceptedCount === null);
23
+ if (pending.length > 0) {
24
+ console.log(chalk.yellow(`\n${pending.length} pending submission(s)`));
25
+ }
26
+ }
@@ -0,0 +1,184 @@
1
+ import { existsSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import chalk from "chalk";
5
+ import ora from "ora";
6
+ import inquirer from "inquirer";
7
+ import { extractClaudeCode, extractCodex, extractCursor } from "@tracemarketplace/shared";
8
+ import { loadConfig } from "../config.js";
9
+ import { ApiClient } from "../api-client.js";
10
+ import { findFiles, CURSOR_DB_PATH } from "../sessions.js";
11
+ import type { NormalizedTrace } from "@tracemarketplace/shared";
12
+
13
+ interface SubmitOptions {
14
+ tool?: string;
15
+ dryRun?: boolean;
16
+ since?: string;
17
+ }
18
+
19
+ function parseSinceDays(since: string): number {
20
+ const match = since.match(/^(\d+)d$/);
21
+ return match ? parseInt(match[1], 10) : 30;
22
+ }
23
+
24
+ export async function submitCommand(opts: SubmitOptions): Promise<void> {
25
+ const config = loadConfig();
26
+ if (!config) {
27
+ console.error(chalk.red("Not registered. Run: trace register"));
28
+ process.exit(1);
29
+ }
30
+
31
+ const sinceDays = parseSinceDays(opts.since ?? "30d");
32
+ const spinner = ora("Discovering sessions...").start();
33
+
34
+ const traces: NormalizedTrace[] = [];
35
+ const errors: string[] = [];
36
+
37
+ const toolAlias: Record<string, string> = {
38
+ "claude-code": "claude_code",
39
+ "codex": "codex_cli",
40
+ };
41
+ const normalizedTool = opts.tool ? (toolAlias[opts.tool] ?? opts.tool) : null;
42
+ const tools = normalizedTool ? [normalizedTool] : ["claude_code", "codex_cli", "cursor"];
43
+
44
+ if (tools.includes("claude_code")) {
45
+ const files = findFiles("claude_code", sinceDays);
46
+ spinner.text = `Found ${files.length} Claude Code sessions`;
47
+ for (const f of files) {
48
+ try {
49
+ traces.push(await extractClaudeCode(f, config.email));
50
+ } catch (e) {
51
+ errors.push(`Claude Code ${f}: ${e}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ if (tools.includes("codex_cli")) {
57
+ const files = findFiles("codex_cli", sinceDays);
58
+ spinner.text = `Found ${files.length} Codex sessions`;
59
+ for (const f of files) {
60
+ try {
61
+ const { readFile } = await import("fs/promises");
62
+ const trace = await extractCodex(await readFile(f), config.email);
63
+ if (trace.turn_count > 0) traces.push(trace);
64
+ } catch (e) {
65
+ errors.push(`Codex ${f}: ${e}`);
66
+ }
67
+ }
68
+ }
69
+
70
+ if (tools.includes("cursor")) {
71
+ if (existsSync(CURSOR_DB_PATH)) {
72
+ try {
73
+ const { default: Database } = await import("better-sqlite3");
74
+ const db = new Database(CURSOR_DB_PATH, { readonly: true });
75
+ const rows = db.prepare("SELECT key FROM cursorDiskKV WHERE key LIKE 'composerData:%'").all() as Array<{ key: string }>;
76
+ db.close();
77
+ spinner.text = `Found ${rows.length} Cursor sessions`;
78
+ for (const { key } of rows) {
79
+ const sessionId = key.replace("composerData:", "");
80
+ try {
81
+ traces.push(await extractCursor(CURSOR_DB_PATH, sessionId, config.email));
82
+ } catch (e) {
83
+ errors.push(`Cursor ${sessionId}: ${e}`);
84
+ }
85
+ }
86
+ } catch (e) {
87
+ errors.push(`Cursor DB: ${e}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ spinner.stop();
93
+
94
+ if (errors.length > 0) {
95
+ console.log(chalk.yellow(`\n${errors.length} extraction error(s):`));
96
+ errors.slice(0, 3).forEach((e) => console.log(chalk.gray(` ${e}`)));
97
+ if (errors.length > 3) console.log(chalk.gray(` ...and ${errors.length - 3} more`));
98
+ }
99
+
100
+ if (traces.length === 0) {
101
+ console.log(chalk.yellow("No sessions found in the last " + sinceDays + " days."));
102
+ return;
103
+ }
104
+
105
+ // Preview table
106
+ console.log(`\n${chalk.bold("Sessions to submit:")} (${traces.length} total)\n`);
107
+ console.log(
108
+ chalk.gray(
109
+ " Tool".padEnd(16) +
110
+ "Session ID".padEnd(20) +
111
+ "Turns".padEnd(8) +
112
+ "Tokens".padEnd(12) +
113
+ "Est. payout"
114
+ )
115
+ );
116
+ console.log(chalk.gray(" " + "─".repeat(60)));
117
+
118
+ for (const t of traces.slice(0, 20)) {
119
+ const tokens = (t.total_input_tokens ?? 0) + (t.total_output_tokens ?? 0);
120
+ const estPayout = t.content_fidelity === "full" ? "$0.04" : "$0.02";
121
+ console.log(
122
+ " " +
123
+ t.source_tool.padEnd(16) +
124
+ t.source_session_id.slice(0, 18).padEnd(20) +
125
+ String(t.turn_count).padEnd(8) +
126
+ String(tokens || "—").padEnd(12) +
127
+ estPayout
128
+ );
129
+ }
130
+ if (traces.length > 20) {
131
+ console.log(chalk.gray(` ... and ${traces.length - 20} more`));
132
+ }
133
+
134
+ if (opts.dryRun) {
135
+ console.log(chalk.cyan("\nDry run — nothing submitted."));
136
+ return;
137
+ }
138
+
139
+ const { confirm } = await inquirer.prompt([
140
+ {
141
+ type: "confirm",
142
+ name: "confirm",
143
+ message: `Submit ${traces.length} traces to ${config.serverUrl}?`,
144
+ default: true,
145
+ },
146
+ ]);
147
+
148
+ if (!confirm) {
149
+ console.log(chalk.gray("Cancelled."));
150
+ return;
151
+ }
152
+
153
+ const uploadSpinner = ora(`Submitting ${traces.length} traces...`).start();
154
+ const client = new ApiClient(config.serverUrl, config.apiKey);
155
+
156
+ try {
157
+ const result = (await client.post("/api/v1/traces/batch", {
158
+ traces,
159
+ source_tool: tools[0] ?? "mixed",
160
+ })) as {
161
+ submission_id: string;
162
+ accepted: number;
163
+ duplicate: number;
164
+ total: number;
165
+ traces: Array<{ payout_cents?: number }>;
166
+ };
167
+
168
+ uploadSpinner.stop();
169
+
170
+ const totalPayout =
171
+ result.traces?.reduce((s, t) => s + (t.payout_cents ?? 0), 0) ?? 0;
172
+
173
+ console.log(chalk.green("\nSubmission complete!"));
174
+ console.log(` Accepted: ${chalk.bold(result.accepted)}`);
175
+ console.log(` Duplicates: ${chalk.gray(result.duplicate)}`);
176
+ console.log(` Total: ${result.total}`);
177
+ console.log(` Payout: ${chalk.green("$" + (totalPayout / 100).toFixed(2))}`);
178
+ console.log(chalk.gray(`\nSubmission ID: ${result.submission_id}`));
179
+ } catch (e) {
180
+ uploadSpinner.fail("Submission failed");
181
+ console.error(chalk.red(String(e)));
182
+ process.exit(1);
183
+ }
184
+ }
@@ -0,0 +1,22 @@
1
+ import chalk from "chalk";
2
+ import { loadConfig } from "../config.js";
3
+ import { ApiClient } from "../api-client.js";
4
+
5
+ export async function whoamiCommand(): Promise<void> {
6
+ const config = loadConfig();
7
+ if (!config) {
8
+ console.error(chalk.red("Not registered. Run: trace register"));
9
+ process.exit(1);
10
+ }
11
+
12
+ const client = new ApiClient(config.serverUrl, config.apiKey);
13
+ const user = (await client.get("/api/v1/me")) as {
14
+ email: string;
15
+ balance_cents?: number;
16
+ balanceCents?: number;
17
+ };
18
+
19
+ const balance = (user.balance_cents ?? user.balanceCents ?? 0) / 100;
20
+ console.log(chalk.bold(user.email));
21
+ console.log(chalk.gray("Balance:"), chalk.green(`$${balance.toFixed(2)}`));
22
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ export interface Config {
6
+ apiKey: string;
7
+ serverUrl: string;
8
+ email: string;
9
+ }
10
+
11
+ export function getConfigPath(): string {
12
+ return join(homedir(), ".config", "tracemarketplace", "config.json");
13
+ }
14
+
15
+ export function loadConfig(): Config | null {
16
+ const p = getConfigPath();
17
+ if (!existsSync(p)) return null;
18
+ try {
19
+ return JSON.parse(readFileSync(p, "utf-8")) as Config;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export function saveConfig(config: Config): void {
26
+ const p = getConfigPath();
27
+ mkdirSync(join(homedir(), ".config", "tracemarketplace"), { recursive: true });
28
+ writeFileSync(p, JSON.stringify(config, null, 2), "utf-8");
29
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * sessions.ts — knows where each tool stores session files.
3
+ * Used by submit (batch discovery), auto-submit (hook path resolution), and daemon (watching).
4
+ */
5
+ import { readdirSync, statSync, existsSync, watch } from "fs";
6
+ import type { FSWatcher } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+
10
+ export type ToolName = "claude_code" | "codex_cli" | "cursor";
11
+
12
+ // Root directories to watch per tool
13
+ export const SESSION_DIRS: Record<ToolName, string> = {
14
+ claude_code: join(homedir(), ".claude", "projects"),
15
+ codex_cli: join(homedir(), ".codex", "sessions"),
16
+ cursor: join(homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage"),
17
+ };
18
+
19
+ export const CURSOR_DB_PATH = join(
20
+ homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"
21
+ );
22
+
23
+ /** Walk a directory and return all .jsonl files modified within the cutoff. */
24
+ function walkJsonl(dir: string, cutoff = 0): string[] {
25
+ const results: string[] = [];
26
+ try {
27
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
28
+ const full = join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ results.push(...walkJsonl(full, cutoff));
31
+ } else if (entry.name.endsWith(".jsonl")) {
32
+ try {
33
+ if (statSync(full).mtimeMs >= cutoff) results.push(full);
34
+ } catch {}
35
+ }
36
+ }
37
+ } catch {}
38
+ return results;
39
+ }
40
+
41
+ /** Find session files for a file-based tool (claude_code, codex_cli) within the last N days. */
42
+ export function findFiles(tool: "claude_code" | "codex_cli", sinceDays = 30): string[] {
43
+ const dir = SESSION_DIRS[tool];
44
+ if (!existsSync(dir)) return [];
45
+ const cutoff = Date.now() - sinceDays * 86400000;
46
+ return walkJsonl(dir, cutoff);
47
+ }
48
+
49
+ /** Find the most recently modified .jsonl file for claude_code (used when hook doesn't give path). */
50
+ export function findLatestFile(tool: "claude_code" | "codex_cli"): string | null {
51
+ const dir = SESSION_DIRS[tool];
52
+ if (!existsSync(dir)) return null;
53
+ const all = walkJsonl(dir, 0);
54
+ if (all.length === 0) return null;
55
+ return all.reduce((a, b) => {
56
+ try { return statSync(a).mtimeMs >= statSync(b).mtimeMs ? a : b; } catch { return a; }
57
+ });
58
+ }
59
+
60
+ /** Find a specific codex session file by UUID (the UUID appears in the filename). */
61
+ export function findCodexFileById(sessionId: string): string | null {
62
+ const dir = SESSION_DIRS["codex_cli"];
63
+ if (!existsSync(dir)) return null;
64
+ const files = walkJsonl(dir, 0);
65
+ return files.find(f => f.includes(sessionId)) ?? null;
66
+ }
67
+
68
+ /**
69
+ * Watch session directories for new/changed .jsonl files.
70
+ * Calls onChange(tool, filePath) after a 5s debounce (lets writes settle).
71
+ * Returns a cleanup function.
72
+ */
73
+ export function watchDirs(
74
+ tools: Array<"claude_code" | "codex_cli">,
75
+ onChange: (tool: "claude_code" | "codex_cli", filePath: string) => void
76
+ ): () => void {
77
+ const watchers: FSWatcher[] = [];
78
+ const debounce = new Map<string, ReturnType<typeof setTimeout>>();
79
+
80
+ for (const tool of tools) {
81
+ const dir = SESSION_DIRS[tool];
82
+ if (!existsSync(dir)) continue;
83
+
84
+ try {
85
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => {
86
+ if (!filename || !filename.endsWith(".jsonl")) return;
87
+ const full = join(dir, filename);
88
+
89
+ // Debounce: wait 5s of no writes before treating the file as settled
90
+ const existing = debounce.get(full);
91
+ if (existing) clearTimeout(existing);
92
+ debounce.set(full, setTimeout(() => {
93
+ debounce.delete(full);
94
+ if (existsSync(full)) onChange(tool, full);
95
+ }, 5000));
96
+ });
97
+ watchers.push(watcher);
98
+ } catch {}
99
+ }
100
+
101
+ return () => {
102
+ for (const w of watchers) w.close();
103
+ for (const t of debounce.values()) clearTimeout(t);
104
+ };
105
+ }
@@ -0,0 +1,89 @@
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 { extractClaudeCode, extractCodex, extractCursor } from "@tracemarketplace/shared";
7
+ import { ApiClient } from "./api-client.js";
8
+ import { CURSOR_DB_PATH } from "./sessions.js";
9
+ import type { Config } from "./config.js";
10
+
11
+ export interface SubmitResult {
12
+ accepted: boolean;
13
+ superseded: boolean;
14
+ duplicate: boolean;
15
+ turnCount: number;
16
+ payoutCents: number;
17
+ traceId: string | null;
18
+ error?: string;
19
+ }
20
+
21
+ /**
22
+ * Extract a claude_code or codex_cli session from a file path and submit it.
23
+ */
24
+ export async function submitFile(
25
+ tool: "claude_code" | "codex_cli",
26
+ filePath: string,
27
+ config: Config
28
+ ): Promise<SubmitResult> {
29
+ let trace;
30
+ try {
31
+ if (tool === "claude_code") {
32
+ trace = await extractClaudeCode(filePath, config.email);
33
+ } else {
34
+ const buf = await readFile(filePath);
35
+ trace = await extractCodex(buf, config.email);
36
+ }
37
+ } catch (err) {
38
+ return { accepted: false, superseded: false, duplicate: false, turnCount: 0, payoutCents: 0, traceId: null, error: `Extraction failed: ${err}` };
39
+ }
40
+
41
+ if (trace.turn_count === 0) {
42
+ return { accepted: false, superseded: false, duplicate: false, turnCount: 0, payoutCents: 0, traceId: null, error: "Empty session" };
43
+ }
44
+
45
+ return submitTrace(trace, config);
46
+ }
47
+
48
+ /**
49
+ * Extract a Cursor session by session ID and submit it.
50
+ */
51
+ export async function submitCursorSession(
52
+ sessionId: string,
53
+ config: Config
54
+ ): Promise<SubmitResult> {
55
+ let trace;
56
+ try {
57
+ trace = await extractCursor(CURSOR_DB_PATH, sessionId, config.email);
58
+ } catch (err) {
59
+ return { accepted: false, superseded: false, duplicate: false, turnCount: 0, payoutCents: 0, traceId: null, error: `Cursor extraction failed: ${err}` };
60
+ }
61
+ return submitTrace(trace, config);
62
+ }
63
+
64
+ async function submitTrace(trace: Awaited<ReturnType<typeof extractClaudeCode>>, config: Config): Promise<SubmitResult> {
65
+ const client = new ApiClient(config.serverUrl, config.apiKey);
66
+ try {
67
+ const result = await client.post("/api/v1/traces/batch", {
68
+ traces: [trace],
69
+ source_tool: trace.source_tool,
70
+ }) as {
71
+ accepted: number;
72
+ duplicate: number;
73
+ superseded: number;
74
+ traces: Array<{ trace_id?: string; payout_cents?: number }>;
75
+ };
76
+
77
+ const first = result.traces?.[0];
78
+ return {
79
+ accepted: result.accepted > 0,
80
+ superseded: result.superseded > 0,
81
+ duplicate: result.duplicate > 0,
82
+ turnCount: trace.turn_count,
83
+ payoutCents: first?.payout_cents ?? 0,
84
+ traceId: first?.trace_id ?? null,
85
+ };
86
+ } catch (err) {
87
+ return { accepted: false, superseded: false, duplicate: false, turnCount: trace.turn_count, payoutCents: 0, traceId: null, error: `Submit failed: ${err}` };
88
+ }
89
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }