monty-cli 0.1.0 → 0.1.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.
- package/monty.js +579 -14
- 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
|
-
|
|
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(),
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|