monty-cli 0.1.0 → 0.1.2

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 (2) hide show
  1. package/monty.js +579 -14
  2. package/package.json +1 -1
package/monty.js CHANGED
@@ -21,6 +21,7 @@ async function main() {
21
21
  if (command === "capture") return captureCommand(args);
22
22
  if (command === "hook") return hookCommandHandler(args);
23
23
  if (command === "run") return runWrapped(args);
24
+ if (command === "sync") return syncHistory(args);
24
25
  if (command === "doctor") return doctor();
25
26
  if (command === "help" || command === "--help" || command === "-h") return help();
26
27
 
@@ -65,8 +66,14 @@ async function install(args) {
65
66
  console.log(`Claude hook: ${claudePath}`);
66
67
  console.log(`Codex hook: ${codexPath}`);
67
68
  console.log(`Codex telemetry: ${codexConfigPath}`);
69
+ console.log("");
70
+
71
+ if (!options["no-sync"]) {
72
+ console.log("Syncing prompt history...");
73
+ await syncHistory(args);
74
+ }
75
+
68
76
  console.log("Open Claude Code or restart Codex CLI and submit a prompt. It will appear in the Monty feed.");
69
- console.log("Note: Codex reads telemetry config at process start, so already-running Codex sessions must be restarted.");
70
77
  }
71
78
 
72
79
  async function captureCommand(args) {
@@ -83,7 +90,112 @@ async function hookCommandHandler(args) {
83
90
  const options = parseArgs(args);
84
91
  const source = options.source || "manual";
85
92
  const input = await readStdinJson();
86
- await sendPrompt(source, input);
93
+
94
+ if (input.hook_event_name === "Stop" && input.session_id && input.transcript_path) {
95
+ await Promise.all([
96
+ handleStopHook(source, input),
97
+ sendHeartbeat(),
98
+ ]);
99
+ return;
100
+ }
101
+
102
+ if (input.hook_event_name === "UserPromptSubmit" && input.session_id && input.transcript_path) {
103
+ handleStopHook(source, input).catch(() => {});
104
+ }
105
+
106
+ await Promise.all([
107
+ sendPrompt(source, input),
108
+ sendHeartbeat(),
109
+ ]);
110
+ }
111
+
112
+ async function handleStopHook(source, input) {
113
+ const config = readConfig();
114
+ const sessionId = input.session_id;
115
+ const transcriptPath = input.transcript_path;
116
+
117
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return;
118
+
119
+ try {
120
+ const content = fs.readFileSync(transcriptPath, "utf8");
121
+ const lines = content.split("\n").filter(Boolean);
122
+
123
+ let lastUserPrompt = null;
124
+ let totalInput = 0;
125
+ let totalOutput = 0;
126
+ let cacheCreation = 0;
127
+ let cacheRead = 0;
128
+ let model = null;
129
+ let turnStarted = false;
130
+ const modifiedFiles = new Set();
131
+
132
+ for (let i = lines.length - 1; i >= 0; i--) {
133
+ let entry;
134
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
135
+
136
+ if (entry.type === "file-history-snapshot" && entry.snapshot?.trackedFileBackups && !turnStarted) {
137
+ for (const filePath of Object.keys(entry.snapshot.trackedFileBackups)) {
138
+ modifiedFiles.add(filePath);
139
+ }
140
+ }
141
+
142
+ if (entry.type === "assistant" && entry.message) {
143
+ const usage = entry.message.usage || {};
144
+ totalInput += usage.input_tokens || 0;
145
+ totalOutput += usage.output_tokens || 0;
146
+ cacheCreation += usage.cache_creation_input_tokens || 0;
147
+ cacheRead += usage.cache_read_input_tokens || 0;
148
+ if (entry.message.model && entry.message.model !== "<synthetic>") model = entry.message.model;
149
+ turnStarted = true;
150
+
151
+ for (const block of entry.message.content || []) {
152
+ if (block.type === "tool_use" && (block.name === "Edit" || block.name === "Write") && block.input?.file_path) {
153
+ const fp = block.input.file_path;
154
+ const cwd = input.cwd || process.cwd();
155
+ modifiedFiles.add(fp.startsWith(cwd) ? fp.slice(cwd.length + 1) : fp);
156
+ }
157
+ }
158
+ }
159
+
160
+ if (entry.type === "user" && entry.message && turnStarted) {
161
+ lastUserPrompt = extractPromptFromClaudeMessage(entry.message);
162
+ break;
163
+ }
164
+ }
165
+
166
+ if (!lastUserPrompt || (totalInput === 0 && totalOutput === 0)) return;
167
+
168
+ const siteUrl = cleanUrl(config.siteUrl || process.env.MONTY_SITE_URL || "https://www.trymonty.ai");
169
+ const headers = { "content-type": "application/json" };
170
+ const token = config.ingestToken || process.env.MONTY_INGEST_TOKEN;
171
+ if (token) headers.authorization = `Bearer ${token}`;
172
+
173
+ const controller = new AbortController();
174
+ const timeout = setTimeout(() => controller.abort(), 8000);
175
+ try {
176
+ await fetch(`${siteUrl}/api/events`, {
177
+ method: "PATCH",
178
+ headers,
179
+ body: JSON.stringify({
180
+ session_id: sessionId,
181
+ prompt: lastUserPrompt,
182
+ model,
183
+ input_tokens: totalInput,
184
+ output_tokens: totalOutput,
185
+ cache_creation_input_tokens: cacheCreation,
186
+ cache_read_input_tokens: cacheRead,
187
+ modified_files: Array.from(modifiedFiles),
188
+ }),
189
+ signal: controller.signal,
190
+ });
191
+ } catch (error) {
192
+ silentLog(`Monty stop-hook failed: ${error instanceof Error ? error.message : String(error)}`);
193
+ } finally {
194
+ clearTimeout(timeout);
195
+ }
196
+ } catch (error) {
197
+ silentLog(`Monty stop-hook transcript parse failed: ${error instanceof Error ? error.message : String(error)}`);
198
+ }
87
199
  }
88
200
 
89
201
  async function sendPrompt(source, input) {
@@ -91,10 +203,14 @@ async function sendPrompt(source, input) {
91
203
  const prompt = extractPrompt(input);
92
204
 
93
205
  if (!prompt) {
94
- silentLog("No prompt found in hook payload.");
95
206
  return;
96
207
  }
97
208
 
209
+ const inputTokens = numberOrNull(input.input_tokens || input.inputTokens) || 0;
210
+ const outputTokens = numberOrNull(input.output_tokens || input.outputTokens) || 0;
211
+ const cacheCreationTokens = numberOrNull(input.cache_creation_input_tokens) || 0;
212
+ const cacheReadTokens = numberOrNull(input.cache_read_input_tokens) || 0;
213
+
98
214
  const event = {
99
215
  team_id: config.teamId || process.env.MONTY_TEAM_ID || "default",
100
216
  source: normalizeSource(source),
@@ -103,13 +219,19 @@ async function sendPrompt(source, input) {
103
219
  avatar_url: config.avatarUrl || process.env.MONTY_AVATAR_URL || null,
104
220
  machine_id: config.machineId || os.hostname(),
105
221
  cwd: input.cwd || process.cwd(),
106
- model: input.model || input.model_id || null,
222
+ model: input.model || input.model_id || detectClaudeModel(source) || null,
107
223
  token_count: numberOrNull(input.token_count || input.tokens || input.total_tokens),
224
+ input_tokens: inputTokens,
225
+ output_tokens: outputTokens,
108
226
  session_id: input.session_id || input.conversation_id || null,
109
227
  metadata: {
110
228
  hook_event_name: input.hook_event_name || input.hookEventName || null,
111
229
  transcript_path: input.transcript_path || null,
112
230
  cli: source,
231
+ input_tokens: inputTokens,
232
+ output_tokens: outputTokens,
233
+ cache_creation_input_tokens: cacheCreationTokens,
234
+ cache_read_input_tokens: cacheReadTokens,
113
235
  },
114
236
  };
115
237
 
@@ -119,7 +241,7 @@ async function sendPrompt(source, input) {
119
241
  if (token) headers.authorization = `Bearer ${token}`;
120
242
 
121
243
  const controller = new AbortController();
122
- const timeout = setTimeout(() => controller.abort(), 2500);
244
+ const timeout = setTimeout(() => controller.abort(), 8000);
123
245
  try {
124
246
  const response = await fetch(`${siteUrl}/api/events`, {
125
247
  method: "POST",
@@ -137,7 +259,34 @@ async function sendPrompt(source, input) {
137
259
  }
138
260
  }
139
261
 
140
- function runWrapped(args) {
262
+ async function sendHeartbeat() {
263
+ const config = readConfig();
264
+ const siteUrl = cleanUrl(config.siteUrl || process.env.MONTY_SITE_URL || "https://www.trymonty.ai");
265
+ const headers = { "content-type": "application/json" };
266
+ const token = config.ingestToken || process.env.MONTY_INGEST_TOKEN;
267
+ if (token) headers.authorization = `Bearer ${token}`;
268
+
269
+ const controller = new AbortController();
270
+ const timeout = setTimeout(() => controller.abort(), 5000);
271
+ try {
272
+ await fetch(`${siteUrl}/api/heartbeat`, {
273
+ method: "POST",
274
+ headers,
275
+ body: JSON.stringify({
276
+ team_id: config.teamId || process.env.MONTY_TEAM_ID || "default",
277
+ user_name: config.userName || process.env.MONTY_USER || process.env.USER || "unknown",
278
+ seconds: 30,
279
+ }),
280
+ signal: controller.signal,
281
+ });
282
+ } catch {
283
+ // Heartbeat failures are non-critical
284
+ } finally {
285
+ clearTimeout(timeout);
286
+ }
287
+ }
288
+
289
+ async function runWrapped(args) {
141
290
  const [tool, ...toolArgs] = args;
142
291
  if (!tool || !["claude", "codex"].includes(tool)) {
143
292
  console.error("Usage: monty run <claude|codex> [...args]");
@@ -147,11 +296,360 @@ function runWrapped(args) {
147
296
 
148
297
  const prompt = inferPromptFromArgs(tool, toolArgs);
149
298
  if (prompt) {
150
- sendPrompt(tool, { prompt, cwd: process.cwd(), hook_event_name: "WrappedRun" }).catch(() => {});
299
+ await sendPrompt(tool, { prompt, cwd: process.cwd(), hook_event_name: "WrappedRun" }).catch(() => {});
151
300
  }
152
301
 
153
302
  const result = spawnSync(tool, toolArgs, { stdio: "inherit", env: process.env });
154
- process.exit(result.status ?? 1);
303
+
304
+ if (tool === "claude") {
305
+ const transcriptPath = findLatestTranscript(process.cwd());
306
+ if (transcriptPath) {
307
+ const sessionId = path.basename(transcriptPath, ".jsonl");
308
+ await handleStopHook(tool, {
309
+ session_id: sessionId,
310
+ transcript_path: transcriptPath,
311
+ cwd: process.cwd(),
312
+ }).catch(() => {});
313
+ }
314
+ }
315
+
316
+ process.exitCode = result.status ?? 1;
317
+ }
318
+
319
+ async function syncHistory(args) {
320
+ const options = parseArgs(args);
321
+ const config = readConfig();
322
+ const siteUrl = cleanUrl(config.siteUrl || options.site || process.env.MONTY_SITE_URL || "https://www.trymonty.ai");
323
+ const teamId = config.teamId || options.team || process.env.MONTY_TEAM_ID || "default";
324
+ const userName = config.userName || options.user || process.env.MONTY_USER || process.env.USER || "unknown";
325
+ const avatarUrl = config.avatarUrl || options.avatar || null;
326
+
327
+ console.log(`Syncing history for ${userName} (team: ${teamId}) to ${siteUrl}`);
328
+
329
+ const allEvents = [];
330
+
331
+ const claudeEvents = parseClaudeHistory(userName, avatarUrl, teamId);
332
+ console.log(`Found ${claudeEvents.length} Claude Code turns`);
333
+ allEvents.push(...claudeEvents);
334
+
335
+ const codexEvents = parseCodexHistory(userName, avatarUrl, teamId);
336
+ console.log(`Found ${codexEvents.length} Codex turns`);
337
+ allEvents.push(...codexEvents);
338
+
339
+ if (allEvents.length === 0) {
340
+ console.log("No history found to sync.");
341
+ return;
342
+ }
343
+
344
+ allEvents.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
345
+ console.log(`Total events to sync: ${allEvents.length}`);
346
+
347
+ const headers = { "content-type": "application/json" };
348
+ const token = config.ingestToken || process.env.MONTY_INGEST_TOKEN;
349
+ if (token) headers.authorization = `Bearer ${token}`;
350
+
351
+ const batchSize = 200;
352
+ let synced = 0;
353
+ for (let i = 0; i < allEvents.length; i += batchSize) {
354
+ const batch = allEvents.slice(i, i + batchSize);
355
+ try {
356
+ const response = await fetch(`${siteUrl}/api/sync`, {
357
+ method: "POST",
358
+ headers,
359
+ body: JSON.stringify({ events: batch }),
360
+ });
361
+ const result = await response.json();
362
+ synced += result.inserted || 0;
363
+ process.stdout.write(`\r Synced ${Math.min(i + batchSize, allEvents.length)}/${allEvents.length}...`);
364
+ } catch (error) {
365
+ console.error(`\nBatch failed: ${error instanceof Error ? error.message : String(error)}`);
366
+ }
367
+ }
368
+ console.log(`\nDone. ${synced} events synced.`);
369
+ }
370
+
371
+ function parseClaudeHistory(userName, avatarUrl, teamId) {
372
+ const claudeDir = path.join(home, ".claude", "projects");
373
+ if (!fs.existsSync(claudeDir)) return [];
374
+
375
+ const events = [];
376
+ const projectDirs = fs.readdirSync(claudeDir).filter((d) => {
377
+ try { return fs.statSync(path.join(claudeDir, d)).isDirectory(); } catch { return false; }
378
+ });
379
+
380
+ for (const projectDir of projectDirs) {
381
+ const fullDir = path.join(claudeDir, projectDir);
382
+ const jsonlFiles = fs.readdirSync(fullDir).filter((f) => f.endsWith(".jsonl"));
383
+
384
+ for (const file of jsonlFiles) {
385
+ const sessionId = file.replace(".jsonl", "");
386
+ const filePath = path.join(fullDir, file);
387
+ try {
388
+ const sessionEvents = parseClaudeSession(filePath, sessionId, userName, avatarUrl, teamId, projectDir);
389
+ events.push(...sessionEvents);
390
+ } catch {
391
+ // skip corrupt files
392
+ }
393
+ }
394
+ }
395
+
396
+ return events;
397
+ }
398
+
399
+ function parseClaudeSession(filePath, sessionId, userName, avatarUrl, teamId, projectDir) {
400
+ const content = fs.readFileSync(filePath, "utf8");
401
+ const lines = content.split("\n").filter(Boolean);
402
+ const events = [];
403
+
404
+ let currentUserPrompt = null;
405
+ let currentCwd = null;
406
+ let turnIndex = 0;
407
+
408
+ for (const line of lines) {
409
+ let entry;
410
+ try { entry = JSON.parse(line); } catch { continue; }
411
+
412
+ if (entry.type === "user" && entry.message) {
413
+ const prompt = extractPromptFromClaudeMessage(entry.message);
414
+ if (prompt && !prompt.startsWith("<task-notification>") && !prompt.startsWith("<system-reminder>") && !prompt.startsWith("<command-name>")) {
415
+ if (currentUserPrompt) {
416
+ if (currentUserPrompt.inputTokens > 0 || currentUserPrompt.outputTokens > 0) {
417
+ events.push(buildClaudeTurnEvent(currentUserPrompt, sessionId, userName, avatarUrl, teamId, projectDir, turnIndex++));
418
+ }
419
+ }
420
+ currentUserPrompt = {
421
+ prompt,
422
+ timestamp: entry.timestamp,
423
+ cwd: entry.cwd || currentCwd,
424
+ model: null,
425
+ inputTokens: 0,
426
+ outputTokens: 0,
427
+ cacheCreationTokens: 0,
428
+ cacheReadTokens: 0,
429
+ seenMsgIds: new Set(),
430
+ };
431
+ if (entry.cwd) currentCwd = entry.cwd;
432
+ }
433
+ }
434
+
435
+ if (entry.type === "assistant" && entry.message) {
436
+ const msg = entry.message;
437
+ const msgId = msg.id;
438
+ const usage = msg.usage || {};
439
+ const model = msg.model || null;
440
+ if (model === "<synthetic>") continue;
441
+
442
+ if (currentUserPrompt && msgId && !currentUserPrompt.seenMsgIds.has(msgId)) {
443
+ currentUserPrompt.seenMsgIds.add(msgId);
444
+ const rawInput = usage.input_tokens || 0;
445
+ const cacheCreation = usage.cache_creation_input_tokens || 0;
446
+ const cacheRead = usage.cache_read_input_tokens || 0;
447
+ const outputTokens = usage.output_tokens || 0;
448
+ currentUserPrompt.inputTokens += rawInput + cacheCreation + cacheRead;
449
+ currentUserPrompt.outputTokens += outputTokens;
450
+ currentUserPrompt.cacheCreationTokens += cacheCreation;
451
+ currentUserPrompt.cacheReadTokens += cacheRead;
452
+ if (model) currentUserPrompt.model = model;
453
+ }
454
+ }
455
+ }
456
+
457
+ if (currentUserPrompt && (currentUserPrompt.inputTokens > 0 || currentUserPrompt.outputTokens > 0)) {
458
+ events.push(buildClaudeTurnEvent(currentUserPrompt, sessionId, userName, avatarUrl, teamId, projectDir, turnIndex));
459
+ }
460
+
461
+ return events;
462
+ }
463
+
464
+ function buildClaudeTurnEvent(turn, sessionId, userName, avatarUrl, teamId, projectDir, turnIndex) {
465
+ const cwd = turn.cwd || projectDirToCwd(projectDir);
466
+ const createdAt = turn.timestamp ? new Date(turn.timestamp).toISOString() : new Date().toISOString();
467
+
468
+ return {
469
+ id: deterministicUUID(`claude-turn-${sessionId}-${turnIndex}`),
470
+ created_at: createdAt,
471
+ team_id: teamId,
472
+ source: "claude",
473
+ prompt: turn.prompt || "",
474
+ user_name: userName,
475
+ avatar_url: avatarUrl,
476
+ machine_id: os.hostname(),
477
+ cwd,
478
+ model: turn.model,
479
+ token_count: turn.inputTokens + turn.outputTokens,
480
+ input_tokens: turn.inputTokens,
481
+ output_tokens: turn.outputTokens,
482
+ session_id: sessionId,
483
+ metadata: {
484
+ cli: "claude",
485
+ hook_event_name: "HistorySync",
486
+ input_tokens: turn.inputTokens,
487
+ output_tokens: turn.outputTokens,
488
+ cache_creation_input_tokens: turn.cacheCreationTokens || 0,
489
+ cache_read_input_tokens: turn.cacheReadTokens || 0,
490
+ },
491
+ };
492
+ }
493
+
494
+ function extractPromptFromClaudeMessage(message) {
495
+ if (typeof message === "string") return message;
496
+ if (message && typeof message.content === "string") return message.content;
497
+ if (message && Array.isArray(message.content)) {
498
+ for (const block of message.content) {
499
+ if (block && block.type === "text" && typeof block.text === "string") return block.text;
500
+ }
501
+ }
502
+ return "";
503
+ }
504
+
505
+ function projectDirToCwd(projectDir) {
506
+ return projectDir.replace(/^-/, "/").replace(/-/g, "/");
507
+ }
508
+
509
+ function parseCodexHistory(userName, avatarUrl, teamId) {
510
+ const dbPath = path.join(home, ".codex", "state_5.sqlite");
511
+ if (!fs.existsSync(dbPath)) return [];
512
+
513
+ try {
514
+ const result = spawnSync("sqlite3", ["-json", dbPath,
515
+ "SELECT id, title, model, tokens_used, model_provider, created_at, cwd, SUBSTR(first_user_message, 1, 500) AS first_user_message, cli_version FROM threads WHERE tokens_used > 0 ORDER BY created_at DESC",
516
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 100 * 1024 * 1024 });
517
+
518
+ if (result.status !== 0 || !result.stdout.trim()) return [];
519
+
520
+ const threads = JSON.parse(result.stdout);
521
+ const allEvents = [];
522
+
523
+ for (const thread of threads) {
524
+ const rolloutTurns = parseCodexRolloutTurns(thread.id);
525
+
526
+ if (rolloutTurns.length > 0) {
527
+ for (let i = 0; i < rolloutTurns.length; i++) {
528
+ const turn = rolloutTurns[i];
529
+ const createdAt = turn.timestamp || (thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString());
530
+ allEvents.push({
531
+ id: deterministicUUID(`codex-turn-${thread.id}-${i}`),
532
+ created_at: createdAt,
533
+ team_id: teamId,
534
+ source: "codex",
535
+ prompt: turn.prompt || "",
536
+ user_name: userName,
537
+ avatar_url: avatarUrl,
538
+ machine_id: os.hostname(),
539
+ cwd: thread.cwd || null,
540
+ model: thread.model || null,
541
+ token_count: turn.inputTokens + turn.outputTokens,
542
+ input_tokens: turn.inputTokens,
543
+ output_tokens: turn.outputTokens,
544
+ session_id: thread.id,
545
+ metadata: {
546
+ cli: "codex",
547
+ hook_event_name: "HistorySync",
548
+ input_tokens: turn.inputTokens,
549
+ output_tokens: turn.outputTokens,
550
+ },
551
+ });
552
+ }
553
+ } else {
554
+ const createdAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
555
+ const tokensUsed = thread.tokens_used || 0;
556
+ allEvents.push({
557
+ id: deterministicUUID(`codex-turn-${thread.id}-0`),
558
+ created_at: createdAt,
559
+ team_id: teamId,
560
+ source: "codex",
561
+ prompt: thread.first_user_message || thread.title || "(session)",
562
+ user_name: userName,
563
+ avatar_url: avatarUrl,
564
+ machine_id: os.hostname(),
565
+ cwd: thread.cwd || null,
566
+ model: thread.model || null,
567
+ token_count: tokensUsed,
568
+ input_tokens: Math.round(tokensUsed * 0.7),
569
+ output_tokens: Math.round(tokensUsed * 0.3),
570
+ session_id: thread.id,
571
+ metadata: {
572
+ cli: "codex",
573
+ hook_event_name: "HistorySync",
574
+ input_tokens: Math.round(tokensUsed * 0.7),
575
+ output_tokens: Math.round(tokensUsed * 0.3),
576
+ },
577
+ });
578
+ }
579
+ }
580
+
581
+ return allEvents;
582
+ } catch {
583
+ return [];
584
+ }
585
+ }
586
+
587
+ function parseCodexRolloutTurns(threadId) {
588
+ const sessionsDir = path.join(home, ".codex", "sessions");
589
+ if (!fs.existsSync(sessionsDir)) return [];
590
+
591
+ try {
592
+ const result = spawnSync("find", [sessionsDir, "-name", `*${threadId}*`, "-type", "f"], {
593
+ encoding: "utf8",
594
+ stdio: ["ignore", "pipe", "ignore"],
595
+ });
596
+
597
+ if (result.status !== 0 || !result.stdout.trim()) return [];
598
+
599
+ const rolloutFile = result.stdout.trim().split("\n")[0];
600
+ if (!rolloutFile || !fs.existsSync(rolloutFile)) return [];
601
+
602
+ const content = fs.readFileSync(rolloutFile, "utf8");
603
+ const turns = [];
604
+ let currentPrompt = null;
605
+ let lastTotalInput = 0;
606
+ let lastTotalOutput = 0;
607
+
608
+ for (const line of content.split("\n")) {
609
+ if (!line) continue;
610
+ let entry;
611
+ try { entry = JSON.parse(line); } catch { continue; }
612
+
613
+ if (entry.type === "response_item" && entry.payload?.role === "user") {
614
+ const textBlock = (entry.payload.content || []).find((c) => c.type === "input_text" && c.text);
615
+ if (textBlock && !textBlock.text.startsWith("<")) {
616
+ if (currentPrompt && currentPrompt.hasTokens) {
617
+ turns.push(currentPrompt);
618
+ }
619
+ currentPrompt = {
620
+ prompt: textBlock.text.slice(0, 500),
621
+ timestamp: entry.timestamp,
622
+ inputTokens: 0,
623
+ outputTokens: 0,
624
+ hasTokens: false,
625
+ };
626
+ }
627
+ }
628
+
629
+ if (entry.type === "event_msg" && entry.payload?.type === "token_count" && entry.payload.info) {
630
+ const total = entry.payload.info.total_token_usage;
631
+ if (total && currentPrompt) {
632
+ const turnInput = (total.input_tokens || 0) - lastTotalInput;
633
+ const turnOutput = (total.output_tokens || 0) - lastTotalOutput;
634
+ if (turnInput > 0 || turnOutput > 0) {
635
+ currentPrompt.inputTokens = turnInput;
636
+ currentPrompt.outputTokens = turnOutput;
637
+ currentPrompt.hasTokens = true;
638
+ lastTotalInput = total.input_tokens || 0;
639
+ lastTotalOutput = total.output_tokens || 0;
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ if (currentPrompt && currentPrompt.hasTokens) {
646
+ turns.push(currentPrompt);
647
+ }
648
+
649
+ return turns;
650
+ } catch {
651
+ return [];
652
+ }
155
653
  }
156
654
 
157
655
  function doctor() {
@@ -174,10 +672,9 @@ function upsertHookJson(filePath, source) {
174
672
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
175
673
  const data = readJson(filePath, {});
176
674
  data.hooks ||= {};
177
- data.hooks.UserPromptSubmit ||= [];
178
675
 
179
676
  const command = `"${process.execPath}" "${installedCliPath}" hook --source ${source}`;
180
- const group = {
677
+ const promptGroup = {
181
678
  hooks: [
182
679
  {
183
680
  type: "command",
@@ -188,11 +685,34 @@ function upsertHookJson(filePath, source) {
188
685
  ],
189
686
  };
190
687
 
191
- data.hooks.UserPromptSubmit = data.hooks.UserPromptSubmit.filter((entry) => {
688
+ const stopMarker = "Monty token sync";
689
+ const stopCommand = `"${process.execPath}" "${installedCliPath}" hook --source ${source}`;
690
+ const stopGroup = {
691
+ hooks: [
692
+ {
693
+ type: "command",
694
+ command: stopCommand,
695
+ timeout: 10,
696
+ statusMessage: stopMarker,
697
+ },
698
+ ],
699
+ };
700
+
701
+ const filterMonty = (entry) => {
192
702
  const serialized = JSON.stringify(entry);
193
- return !serialized.includes(installedCliPath) && !serialized.includes(installedCliDisplayPath) && !serialized.includes(marker);
194
- });
195
- data.hooks.UserPromptSubmit.push(group);
703
+ return !serialized.includes(installedCliPath) && !serialized.includes(installedCliDisplayPath) && !serialized.includes(marker) && !serialized.includes(stopMarker);
704
+ };
705
+
706
+ data.hooks.UserPromptSubmit ||= [];
707
+ data.hooks.UserPromptSubmit = data.hooks.UserPromptSubmit.filter(filterMonty);
708
+ data.hooks.UserPromptSubmit.push(promptGroup);
709
+
710
+ if (source === "claude") {
711
+ data.hooks.Stop ||= [];
712
+ data.hooks.Stop = data.hooks.Stop.filter(filterMonty);
713
+ data.hooks.Stop.push(stopGroup);
714
+ }
715
+
196
716
  writeJson(filePath, data);
197
717
  }
198
718
 
@@ -229,6 +749,38 @@ function otelEndpoint(config) {
229
749
  return `${cleanUrl(config.siteUrl)}/api/otel/v1/logs?${params.toString()}`;
230
750
  }
231
751
 
752
+ function findLatestTranscript(cwd) {
753
+ const projectDir = cwd.replace(/\//g, "-");
754
+ const projectPath = path.join(home, ".claude", "projects", projectDir);
755
+ if (!fs.existsSync(projectPath)) return null;
756
+
757
+ let latest = null;
758
+ let latestMtime = 0;
759
+ try {
760
+ for (const file of fs.readdirSync(projectPath)) {
761
+ if (!file.endsWith(".jsonl")) continue;
762
+ const filePath = path.join(projectPath, file);
763
+ const stat = fs.statSync(filePath);
764
+ if (stat.mtimeMs > latestMtime) {
765
+ latestMtime = stat.mtimeMs;
766
+ latest = filePath;
767
+ }
768
+ }
769
+ } catch {}
770
+ return latest;
771
+ }
772
+
773
+ function detectClaudeModel(source) {
774
+ if (source !== "claude") return null;
775
+ try {
776
+ const settingsPath = path.join(home, ".claude", "settings.json");
777
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
778
+ return settings.model || null;
779
+ } catch {
780
+ return null;
781
+ }
782
+ }
783
+
232
784
  function detectGitHubProfile(options = {}) {
233
785
  const explicitLogin = options.github || options["github-login"] || process.env.MONTY_GITHUB_LOGIN || "";
234
786
  const explicitAvatar = options.avatar || options["avatar-url"] || process.env.MONTY_AVATAR_URL || "";
@@ -372,6 +924,18 @@ function escapeRegExp(value) {
372
924
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
373
925
  }
374
926
 
927
+ function deterministicUUID(input) {
928
+ const crypto = require("node:crypto");
929
+ const hash = crypto.createHash("sha256").update(input).digest("hex");
930
+ return [
931
+ hash.slice(0, 8),
932
+ hash.slice(8, 12),
933
+ "4" + hash.slice(13, 16),
934
+ ((parseInt(hash.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") + hash.slice(18, 20),
935
+ hash.slice(20, 32),
936
+ ].join("-");
937
+ }
938
+
375
939
  function normalizeSource(value) {
376
940
  return value === "claude" || value === "codex" || value === "test" ? value : "manual";
377
941
  }
@@ -399,6 +963,7 @@ function help() {
399
963
 
400
964
  Usage:
401
965
  monty install --site http://localhost:3000 --team default
966
+ monty sync Sync all Claude Code & Codex history with real token counts
402
967
  monty capture --source test --prompt "hello"
403
968
  monty run codex exec "prompt"
404
969
  monty run claude -p "prompt"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monty-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Live AI prompt feed and token leaderboard for your engineering team. Works with Claude Code and Codex CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {