panopticon-cli 0.5.3 → 0.5.4

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.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Proportional cost recovery: for multi-issue sessions, splits costs
4
+ * based on which issue was being discussed at each point in the conversation.
5
+ *
6
+ * Strategy: for each assistant response with usage, look at the surrounding
7
+ * context (the human message before it and assistant message itself) to determine
8
+ * which issue is being worked on at that moment.
9
+ */
10
+
11
+ import { readdirSync, readFileSync, statSync } from 'fs';
12
+ import { join, basename } from 'path';
13
+ import { homedir } from 'os';
14
+ import Database from 'better-sqlite3';
15
+
16
+ const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
17
+ const DB_PATH = join(homedir(), '.panopticon', 'panopticon.db');
18
+
19
+ const PRICING = [
20
+ { provider: 'anthropic', model: 'claude-opus-4.6', inputPer1k: 5e-3, outputPer1k: 0.025, cacheReadPer1k: 5e-4, cacheWrite5mPer1k: 625e-5 },
21
+ { provider: 'anthropic', model: 'claude-opus-4-6', inputPer1k: 5e-3, outputPer1k: 0.025, cacheReadPer1k: 5e-4, cacheWrite5mPer1k: 625e-5 },
22
+ { provider: 'anthropic', model: 'claude-opus-4-1', inputPer1k: 0.015, outputPer1k: 0.075, cacheReadPer1k: 15e-4, cacheWrite5mPer1k: 0.01875 },
23
+ { provider: 'anthropic', model: 'claude-opus-4', inputPer1k: 0.015, outputPer1k: 0.075, cacheReadPer1k: 15e-4, cacheWrite5mPer1k: 0.01875 },
24
+ { provider: 'anthropic', model: 'claude-sonnet-4.5', inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5 },
25
+ { provider: 'anthropic', model: 'claude-sonnet-4-6', inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5 },
26
+ { provider: 'anthropic', model: 'claude-sonnet-4', inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5 },
27
+ { provider: 'anthropic', model: 'claude-haiku-4.5', inputPer1k: 1e-3, outputPer1k: 5e-3, cacheReadPer1k: 1e-4, cacheWrite5mPer1k: 125e-5 },
28
+ { provider: 'anthropic', model: 'claude-haiku-4', inputPer1k: 1e-3, outputPer1k: 5e-3, cacheReadPer1k: 1e-4, cacheWrite5mPer1k: 125e-5 },
29
+ { provider: 'anthropic', model: 'claude-haiku-3', inputPer1k: 25e-5, outputPer1k: 125e-5, cacheReadPer1k: 3e-5, cacheWrite5mPer1k: 3e-4 },
30
+ { provider: 'custom', model: 'kimi-k2.5', inputPer1k: 6e-4, outputPer1k: 2e-3, cacheReadPer1k: 6e-5, cacheWrite5mPer1k: 75e-5 },
31
+ { provider: 'custom', model: 'kimi-for-coding', inputPer1k: 6e-4, outputPer1k: 2e-3, cacheReadPer1k: 6e-5, cacheWrite5mPer1k: 75e-5 },
32
+ ];
33
+
34
+ function getPricing(model) {
35
+ return PRICING.find(p => model.startsWith(p.model)) || PRICING.find(p => model.includes(p.model)) || null;
36
+ }
37
+
38
+ function calculateCost(usage, pricing) {
39
+ let cost = 0;
40
+ let inputMul = 1, outputMul = 1;
41
+ const totalInput = usage.input + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
42
+ if (pricing.model.includes('sonnet-4') && totalInput > 200000) { inputMul = 2; outputMul = 1.5; }
43
+ cost += usage.input / 1000 * pricing.inputPer1k * inputMul;
44
+ cost += usage.output / 1000 * pricing.outputPer1k * outputMul;
45
+ if (usage.cacheRead && pricing.cacheReadPer1k) cost += usage.cacheRead / 1000 * pricing.cacheReadPer1k;
46
+ if (usage.cacheWrite && pricing.cacheWrite5mPer1k) cost += usage.cacheWrite / 1000 * pricing.cacheWrite5mPer1k;
47
+ return Math.round(cost * 1e6) / 1e6;
48
+ }
49
+
50
+ const ISSUE_RE = /\b(PAN|MIN|AUR|KRUX|CLI)-(\d+)\b/gi;
51
+
52
+ function extractIssues(text) {
53
+ const counts = {};
54
+ let match;
55
+ const re = new RegExp(ISSUE_RE.source, 'gi');
56
+ while ((match = re.exec(text)) !== null) {
57
+ const id = `${match[1].toUpperCase()}-${match[2]}`;
58
+ counts[id] = (counts[id] || 0) + 1;
59
+ }
60
+ return counts;
61
+ }
62
+
63
+ function inferIssueFromPath(dirName) {
64
+ const match = dirName.match(/(pan|min|aud|krux|cli)[-](\d+)/i);
65
+ if (match) return `${match[1].toUpperCase()}-${match[2]}`;
66
+ return null;
67
+ }
68
+
69
+ // Main
70
+ const db = new Database(DB_PATH);
71
+ db.pragma('journal_mode = WAL');
72
+
73
+ const insert = db.prepare(`
74
+ INSERT OR IGNORE INTO cost_events (
75
+ ts, agent_id, issue_id, session_type, provider, model,
76
+ input, output, cache_read, cache_write, cost, request_id, source_file
77
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78
+ `);
79
+
80
+ let totalInserted = 0;
81
+ let totalDuplicates = 0;
82
+ let totalUnattributed = 0;
83
+ const issueStats = {};
84
+
85
+ const projectDirs = readdirSync(CLAUDE_PROJECTS);
86
+
87
+ for (const dirName of projectDirs) {
88
+ const projectDir = join(CLAUDE_PROJECTS, dirName);
89
+ try { if (!statSync(projectDir).isDirectory()) continue; } catch { continue; }
90
+
91
+ const pathIssueId = inferIssueFromPath(dirName);
92
+
93
+ let transcripts;
94
+ try {
95
+ transcripts = readdirSync(projectDir)
96
+ .filter(f => f.endsWith('.jsonl'))
97
+ .map(f => join(projectDir, f))
98
+ .filter(f => { try { return statSync(f).isFile(); } catch { return false; } });
99
+ } catch { continue; }
100
+
101
+ for (const transcript of transcripts) {
102
+ let content;
103
+ try { content = readFileSync(transcript, 'utf-8'); } catch { continue; }
104
+ const lines = content.split('\n').filter(l => l.trim());
105
+
106
+ // Parse all entries
107
+ const entries = [];
108
+ for (const line of lines) {
109
+ try { entries.push(JSON.parse(line)); } catch {}
110
+ }
111
+
112
+ // Track the "current issue context" as we walk through the conversation
113
+ let currentIssue = pathIssueId || null;
114
+ let lastHumanText = '';
115
+
116
+ for (let i = 0; i < entries.length; i++) {
117
+ const entry = entries[i];
118
+
119
+ // Track human messages to build context window
120
+ if (entry.type === 'human') {
121
+ const text = JSON.stringify(entry.message || '');
122
+ lastHumanText = text;
123
+ // Update current issue if this human message mentions issues
124
+ const issues = extractIssues(text);
125
+ const sorted = Object.entries(issues).sort((a, b) => b[1] - a[1]);
126
+ if (sorted.length > 0) {
127
+ currentIssue = sorted[0][0];
128
+ }
129
+ continue;
130
+ }
131
+
132
+ if (entry.type !== 'assistant' || !entry.message?.usage) continue;
133
+
134
+ const usage = entry.message.usage;
135
+ const model = entry.message.model || 'claude-sonnet-4';
136
+ const requestId = entry.requestId;
137
+ if (!requestId) continue;
138
+
139
+ const input = usage.input_tokens || 0;
140
+ const output = usage.output_tokens || 0;
141
+ const cacheRead = usage.cache_read_input_tokens || 0;
142
+ const cacheWrite = usage.cache_creation_input_tokens || 0;
143
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) continue;
144
+
145
+ // Check this assistant message for issue mentions too
146
+ const assistantText = JSON.stringify(entry.message?.content || '');
147
+ const assistantIssues = extractIssues(assistantText);
148
+ const combinedText = lastHumanText + ' ' + assistantText;
149
+ const contextIssues = extractIssues(combinedText);
150
+ const sorted = Object.entries(contextIssues).sort((a, b) => b[1] - a[1]);
151
+
152
+ // Use the most-mentioned issue in the immediate context, falling back to running context
153
+ let issueId = null;
154
+ if (sorted.length > 0) {
155
+ issueId = sorted[0][0];
156
+ currentIssue = issueId; // Update running context
157
+ } else {
158
+ issueId = currentIssue;
159
+ }
160
+
161
+ if (!issueId) {
162
+ totalUnattributed++;
163
+ continue;
164
+ }
165
+
166
+ let provider = 'anthropic';
167
+ if (model.includes('gpt')) provider = 'openai';
168
+ else if (model.includes('gemini')) provider = 'google';
169
+ else if (model.includes('kimi')) provider = 'custom';
170
+
171
+ const pricing = getPricing(model);
172
+ if (!pricing) continue;
173
+
174
+ const cost = calculateCost({ input, output, cacheRead, cacheWrite }, pricing);
175
+ const ts = entry.timestamp || new Date(statSync(transcript).mtime).toISOString();
176
+
177
+ const result = insert.run(
178
+ ts, 'recovered-proportional', issueId, 'interactive', provider, model,
179
+ input, output, cacheRead, cacheWrite, cost, requestId, basename(transcript)
180
+ );
181
+
182
+ if (result.changes > 0) {
183
+ totalInserted++;
184
+ if (!issueStats[issueId]) issueStats[issueId] = { inserted: 0, cost: 0 };
185
+ issueStats[issueId].inserted++;
186
+ issueStats[issueId].cost += cost;
187
+ } else {
188
+ totalDuplicates++;
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ db.close();
195
+
196
+ console.log(`\nProportional Cost Recovery Complete`);
197
+ console.log(` NEW events inserted: ${totalInserted}`);
198
+ console.log(` Duplicates skipped: ${totalDuplicates}`);
199
+ console.log(` Unattributable: ${totalUnattributed}`);
200
+ console.log(`\nNewly recovered costs by issue:`);
201
+ const sorted = Object.entries(issueStats).sort((a, b) => b[1].cost - a[1].cost);
202
+ for (const [id, stats] of sorted) {
203
+ console.log(` ${id.padEnd(12)} ${String(stats.inserted).padStart(5)} events $${stats.cost.toFixed(2)}`);
204
+ }
205
+ const totalCost = sorted.reduce((sum, [, s]) => sum + s.cost, 0);
206
+ console.log(`\n TOTAL NEWLY RECOVERED: $${totalCost.toFixed(2)}`);
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Recover cost events from Claude Code transcript files.
4
+ *
5
+ * Scans ~/.claude/projects/ for transcript JSONL files,
6
+ * infers issue ID from directory path, extracts usage data,
7
+ * and inserts into the panopticon SQLite database.
8
+ *
9
+ * Deduplication is handled by the UNIQUE index on request_id.
10
+ */
11
+
12
+ import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
13
+ import { join, basename } from 'path';
14
+ import { homedir } from 'os';
15
+ import Database from 'better-sqlite3';
16
+
17
+ const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
18
+ const DB_PATH = join(homedir(), '.panopticon', 'panopticon.db');
19
+
20
+ // Pricing table (same as record-cost-event.js)
21
+ const PRICING = [
22
+ { provider: 'anthropic', model: 'claude-opus-4.6', inputPer1k: 5e-3, outputPer1k: 0.025, cacheReadPer1k: 5e-4, cacheWrite5mPer1k: 625e-5, cacheWrite1hPer1k: 0.01 },
23
+ { provider: 'anthropic', model: 'claude-opus-4-6', inputPer1k: 5e-3, outputPer1k: 0.025, cacheReadPer1k: 5e-4, cacheWrite5mPer1k: 625e-5, cacheWrite1hPer1k: 0.01 },
24
+ { provider: 'anthropic', model: 'claude-opus-4-1', inputPer1k: 0.015, outputPer1k: 0.075, cacheReadPer1k: 15e-4, cacheWrite5mPer1k: 0.01875, cacheWrite1hPer1k: 0.03 },
25
+ { provider: 'anthropic', model: 'claude-opus-4', inputPer1k: 0.015, outputPer1k: 0.075, cacheReadPer1k: 15e-4, cacheWrite5mPer1k: 0.01875, cacheWrite1hPer1k: 0.03 },
26
+ { provider: 'anthropic', model: 'claude-sonnet-4.5', inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5, cacheWrite1hPer1k: 6e-3 },
27
+ { provider: 'anthropic', model: 'claude-sonnet-4-6', inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5, cacheWrite1hPer1k: 6e-3 },
28
+ { provider: 'anthropic', model: 'claude-sonnet-4', inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5, cacheWrite1hPer1k: 6e-3 },
29
+ { provider: 'anthropic', model: 'claude-haiku-4.5', inputPer1k: 1e-3, outputPer1k: 5e-3, cacheReadPer1k: 1e-4, cacheWrite5mPer1k: 125e-5, cacheWrite1hPer1k: 2e-3 },
30
+ { provider: 'anthropic', model: 'claude-haiku-4', inputPer1k: 1e-3, outputPer1k: 5e-3, cacheReadPer1k: 1e-4, cacheWrite5mPer1k: 125e-5, cacheWrite1hPer1k: 2e-3 },
31
+ { provider: 'anthropic', model: 'claude-haiku-3', inputPer1k: 25e-5, outputPer1k: 125e-5, cacheReadPer1k: 3e-5, cacheWrite5mPer1k: 3e-4, cacheWrite1hPer1k: 5e-4 },
32
+ { provider: 'custom', model: 'kimi-k2.5', inputPer1k: 6e-4, outputPer1k: 2e-3, cacheReadPer1k: 6e-5, cacheWrite5mPer1k: 75e-5 },
33
+ { provider: 'custom', model: 'kimi-for-coding', inputPer1k: 6e-4, outputPer1k: 2e-3, cacheReadPer1k: 6e-5, cacheWrite5mPer1k: 75e-5 },
34
+ ];
35
+
36
+ function getPricing(model) {
37
+ return PRICING.find(p => model.startsWith(p.model)) || PRICING.find(p => model.includes(p.model)) || null;
38
+ }
39
+
40
+ function calculateCost(usage, pricing) {
41
+ let cost = 0;
42
+ let inputMul = 1, outputMul = 1;
43
+ const totalInput = usage.input + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
44
+ if ((pricing.model.includes('sonnet-4')) && totalInput > 200000) {
45
+ inputMul = 2; outputMul = 1.5;
46
+ }
47
+ cost += usage.input / 1000 * pricing.inputPer1k * inputMul;
48
+ cost += usage.output / 1000 * pricing.outputPer1k * outputMul;
49
+ if (usage.cacheRead && pricing.cacheReadPer1k) cost += usage.cacheRead / 1000 * pricing.cacheReadPer1k;
50
+ if (usage.cacheWrite && pricing.cacheWrite5mPer1k) cost += usage.cacheWrite / 1000 * pricing.cacheWrite5mPer1k;
51
+ return Math.round(cost * 1e6) / 1e6;
52
+ }
53
+
54
+ function inferIssueId(dirName) {
55
+ const match = dirName.match(/(pan|min|aud|krux|cli)[-](\d+)/i);
56
+ if (match) return `${match[1].toUpperCase()}-${match[2]}`;
57
+ return null;
58
+ }
59
+
60
+ function findTranscripts(projectDir) {
61
+ const transcripts = [];
62
+ try {
63
+ const entries = readdirSync(projectDir, { recursive: true });
64
+ for (const entry of entries) {
65
+ if (entry.endsWith('.jsonl')) {
66
+ const full = join(projectDir, entry);
67
+ try { if (statSync(full).isFile()) transcripts.push(full); } catch {}
68
+ }
69
+ }
70
+ } catch {}
71
+ return transcripts;
72
+ }
73
+
74
+ // Main
75
+ const db = new Database(DB_PATH);
76
+ db.pragma('journal_mode = WAL');
77
+
78
+ const insert = db.prepare(`
79
+ INSERT OR IGNORE INTO cost_events (
80
+ ts, agent_id, issue_id, session_type, provider, model,
81
+ input, output, cache_read, cache_write, cost, request_id, source_file
82
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
83
+ `);
84
+
85
+ let totalInserted = 0;
86
+ let totalDuplicates = 0;
87
+ let totalErrors = 0;
88
+ const issueStats = {};
89
+
90
+ const projectDirs = readdirSync(CLAUDE_PROJECTS);
91
+ for (const dirName of projectDirs) {
92
+ const issueId = inferIssueId(dirName);
93
+ if (!issueId) continue;
94
+
95
+ const projectDir = join(CLAUDE_PROJECTS, dirName);
96
+ if (!statSync(projectDir).isDirectory()) continue;
97
+
98
+ const transcripts = findTranscripts(projectDir);
99
+ if (transcripts.length === 0) continue;
100
+
101
+ for (const transcript of transcripts) {
102
+ let content;
103
+ try { content = readFileSync(transcript, 'utf-8'); } catch { continue; }
104
+
105
+ const lines = content.split('\n');
106
+ for (const line of lines) {
107
+ if (!line.trim()) continue;
108
+ try {
109
+ const entry = JSON.parse(line);
110
+ if (entry.type !== 'assistant' || !entry.message?.usage) continue;
111
+
112
+ const usage = entry.message.usage;
113
+ const model = entry.message.model || 'claude-sonnet-4';
114
+ const requestId = entry.requestId;
115
+ if (!requestId) continue;
116
+
117
+ const input = usage.input_tokens || 0;
118
+ const output = usage.output_tokens || 0;
119
+ const cacheRead = usage.cache_read_input_tokens || 0;
120
+ const cacheWrite = usage.cache_creation_input_tokens || 0;
121
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) continue;
122
+
123
+ let provider = 'anthropic';
124
+ if (model.includes('gpt')) provider = 'openai';
125
+ else if (model.includes('gemini')) provider = 'google';
126
+ else if (model.includes('kimi')) provider = 'custom';
127
+
128
+ const pricing = getPricing(model);
129
+ if (!pricing) continue;
130
+
131
+ const cost = calculateCost({ input, output, cacheRead, cacheWrite }, pricing);
132
+
133
+ // Use timestamp from the entry if available, otherwise from transcript modification time
134
+ const ts = entry.timestamp || new Date(statSync(transcript).mtime).toISOString();
135
+
136
+ const result = insert.run(
137
+ ts, 'recovered', issueId, 'interactive', provider, model,
138
+ input, output, cacheRead, cacheWrite, cost, requestId, basename(transcript)
139
+ );
140
+
141
+ if (result.changes > 0) {
142
+ totalInserted++;
143
+ if (!issueStats[issueId]) issueStats[issueId] = { inserted: 0, cost: 0 };
144
+ issueStats[issueId].inserted++;
145
+ issueStats[issueId].cost += cost;
146
+ } else {
147
+ totalDuplicates++;
148
+ }
149
+ } catch {
150
+ totalErrors++;
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ db.close();
157
+
158
+ // Report
159
+ console.log(`\nCost Recovery Complete`);
160
+ console.log(` Inserted: ${totalInserted} new events`);
161
+ console.log(` Duplicates skipped: ${totalDuplicates}`);
162
+ console.log(` Errors: ${totalErrors}`);
163
+ console.log(`\nRecovered costs by issue:`);
164
+ const sorted = Object.entries(issueStats).sort((a, b) => b[1].cost - a[1].cost);
165
+ for (const [id, stats] of sorted) {
166
+ console.log(` ${id.padEnd(12)} ${stats.inserted} events $${stats.cost.toFixed(2)}`);
167
+ }
168
+ const totalCost = sorted.reduce((sum, [, s]) => sum + s.cost, 0);
169
+ console.log(`\n TOTAL RECOVERED: $${totalCost.toFixed(2)}`);