@ijfw/memory-server 1.3.0
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/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- package/templates/design/warm-organic.md +84 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IJFW cost/aggregator.js
|
|
3
|
+
* Combines Claude, Codex, Gemini readers. Groups by day/platform/session/model.
|
|
4
|
+
* Returns { costs, savings, breakdowns, series } for dashboard endpoints.
|
|
5
|
+
* Never throws -- failed readers log and return empty.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readClaudeSessions } from './readers/claude.js';
|
|
9
|
+
import { readCodexSessions } from './readers/codex.js';
|
|
10
|
+
import { readGeminiSessions } from './readers/gemini.js';
|
|
11
|
+
import { computeCost } from './pricing.js';
|
|
12
|
+
import { computeSavings, getSavingsMethodology } from './savings.js';
|
|
13
|
+
|
|
14
|
+
const MS_PER_DAY = 86400000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read all turns across all platforms, optionally filtered to recent N days.
|
|
18
|
+
*/
|
|
19
|
+
function readAllTurns(days) {
|
|
20
|
+
const readers = [
|
|
21
|
+
{ fn: readClaudeSessions, label: 'claude' },
|
|
22
|
+
{ fn: readCodexSessions, label: 'codex' },
|
|
23
|
+
{ fn: readGeminiSessions, label: 'gemini' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const cutoff = days ? Date.now() - days * MS_PER_DAY : null;
|
|
27
|
+
let turns = [];
|
|
28
|
+
|
|
29
|
+
for (const { fn, label } of readers) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fn();
|
|
32
|
+
for (const t of raw) {
|
|
33
|
+
if (cutoff && t.timestamp) {
|
|
34
|
+
const ts = new Date(t.timestamp).getTime();
|
|
35
|
+
if (!isNaN(ts) && ts < cutoff) continue;
|
|
36
|
+
}
|
|
37
|
+
turns.push(t);
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// reader failed -- log and continue
|
|
41
|
+
process.stderr.write(`[ijfw-cost] ${label} reader error: ${err.message}\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return turns;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Annotate each turn with its USD cost and return enriched turns.
|
|
50
|
+
*
|
|
51
|
+
* `theoretical_cost_usd` is what equivalent paid-API usage would cost.
|
|
52
|
+
* `cost_usd` is what the user actually paid: 0 for Max-subscription Claude
|
|
53
|
+
* sessions, equal to theoretical_cost_usd for paid-API sessions and for
|
|
54
|
+
* other platforms.
|
|
55
|
+
*/
|
|
56
|
+
function annotateCosts(turns) {
|
|
57
|
+
return turns.map(t => {
|
|
58
|
+
const theoretical = computeCost(t.model, t);
|
|
59
|
+
const real = t.billing_mode === 'max' ? 0 : theoretical;
|
|
60
|
+
return { ...t, cost_usd: real, theoretical_cost_usd: theoretical };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the main cost + savings response.
|
|
66
|
+
* @param {number|null} days - rolling window in days (null = all-time)
|
|
67
|
+
* @param {Array} observations - observation records for savings context
|
|
68
|
+
*/
|
|
69
|
+
export function buildCostReport(days, observations = []) {
|
|
70
|
+
const raw = readAllTurns(days);
|
|
71
|
+
const turns = annotateCosts(raw);
|
|
72
|
+
const savings = computeSavings(turns, observations);
|
|
73
|
+
|
|
74
|
+
// Split measured (real API data) vs estimated (Codex/Gemini char-heuristic)
|
|
75
|
+
const measuredTurns = turns.filter(t => !t.estimated);
|
|
76
|
+
const estimatedTurns = turns.filter(t => t.estimated);
|
|
77
|
+
|
|
78
|
+
const measuredCost = measuredTurns.reduce((s, t) => s + t.cost_usd, 0);
|
|
79
|
+
const estimatedCost = estimatedTurns.reduce((s, t) => s + t.cost_usd, 0);
|
|
80
|
+
const totalCost = measuredCost + estimatedCost;
|
|
81
|
+
|
|
82
|
+
// Theoretical totals: what equivalent paid-API usage would cost.
|
|
83
|
+
// For Max sessions this is the value captured by the subscription.
|
|
84
|
+
const measuredTheoretical = measuredTurns.reduce((s, t) => s + (t.theoretical_cost_usd || 0), 0);
|
|
85
|
+
const estimatedTheoretical = estimatedTurns.reduce((s, t) => s + (t.theoretical_cost_usd || 0), 0);
|
|
86
|
+
const theoreticalTotal = measuredTheoretical + estimatedTheoretical;
|
|
87
|
+
const valueCaptured = theoreticalTotal - totalCost;
|
|
88
|
+
|
|
89
|
+
// Token counts from measured turns only (estimated tokens are unreliable)
|
|
90
|
+
const totalIn = measuredTurns.reduce((s, t) => s + (t.input_tokens || 0), 0);
|
|
91
|
+
const totalOut = measuredTurns.reduce((s, t) => s + (t.output_tokens || 0), 0);
|
|
92
|
+
const totalCacheRead = measuredTurns.reduce((s, t) => s + (t.cache_read_tokens || 0), 0);
|
|
93
|
+
|
|
94
|
+
// Cache hit rate: cache_read / (input + cache_read), measured turns only
|
|
95
|
+
const denominator = totalIn + totalCacheRead;
|
|
96
|
+
const cacheHitRate = denominator > 0 ? totalCacheRead / denominator : 0;
|
|
97
|
+
|
|
98
|
+
// baseline_cost: what measured turns would have cost without prompt caching
|
|
99
|
+
const baselineCost = measuredCost + (savings.cache ? savings.cache.value : 0);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
measuredCost,
|
|
103
|
+
estimatedCost,
|
|
104
|
+
totalCost,
|
|
105
|
+
// What paid-API equivalent would have cost; for Max users the gap
|
|
106
|
+
// (theoreticalTotal - totalCost) is the subscription's captured value.
|
|
107
|
+
theoreticalCost: theoreticalTotal,
|
|
108
|
+
valueCaptured,
|
|
109
|
+
estimationConfidence: estimatedTurns.length > 0 ? 'low' : null,
|
|
110
|
+
// Legacy field kept for callers that haven't migrated yet
|
|
111
|
+
cost: totalCost,
|
|
112
|
+
baseline_cost: baselineCost,
|
|
113
|
+
savings: {
|
|
114
|
+
...savings,
|
|
115
|
+
// Honest framing: cache discount is automatic in Claude Code -- IJFW measures it
|
|
116
|
+
labelShort: 'Cache efficiency (Claude Code automatic caching)',
|
|
117
|
+
labelLong: `Cache hit rate: ${(cacheHitRate * 100).toFixed(1)}% of context served from cache. Claude Code caches automatically; IJFW measures the effect.`,
|
|
118
|
+
},
|
|
119
|
+
tokens: { input: totalIn, output: totalOut, cache_read: totalCacheRead },
|
|
120
|
+
cacheHitRate,
|
|
121
|
+
turnCount: turns.length,
|
|
122
|
+
measuredTurnCount: measuredTurns.length,
|
|
123
|
+
estimatedTurnCount: estimatedTurns.length,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build a breakdown grouped by a dimension.
|
|
129
|
+
* dim: 'platform' | 'session' | 'model' | 'tool'
|
|
130
|
+
*/
|
|
131
|
+
export function buildBreakdown(dim, days, _observations = []) {
|
|
132
|
+
const raw = readAllTurns(days);
|
|
133
|
+
const turns = annotateCosts(raw);
|
|
134
|
+
|
|
135
|
+
const groups = {};
|
|
136
|
+
for (const t of turns) {
|
|
137
|
+
const key = t[dim] || 'unknown';
|
|
138
|
+
if (!groups[key]) groups[key] = { key, cost_usd: 0, theoretical_cost_usd: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, count: 0 };
|
|
139
|
+
groups[key].cost_usd += t.cost_usd;
|
|
140
|
+
groups[key].theoretical_cost_usd += t.theoretical_cost_usd || 0;
|
|
141
|
+
groups[key].input_tokens += t.input_tokens || 0;
|
|
142
|
+
groups[key].output_tokens += t.output_tokens || 0;
|
|
143
|
+
groups[key].cache_read_tokens += t.cache_read_tokens || 0;
|
|
144
|
+
groups[key].count++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sort by theoretical cost so Max-session breakdowns still rank by usage
|
|
148
|
+
// intensity even when cost_usd is uniformly zero.
|
|
149
|
+
return Object.values(groups).sort((a, b) => b.theoretical_cost_usd - a.theoretical_cost_usd);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build daily series for sparkline (last N days).
|
|
154
|
+
*/
|
|
155
|
+
export function buildDailySeries(days = 30) {
|
|
156
|
+
const raw = readAllTurns(days);
|
|
157
|
+
const turns = annotateCosts(raw);
|
|
158
|
+
|
|
159
|
+
const byDay = {};
|
|
160
|
+
for (const t of turns) {
|
|
161
|
+
if (!t.timestamp) continue;
|
|
162
|
+
const d = t.timestamp.slice(0, 10); // YYYY-MM-DD
|
|
163
|
+
if (!byDay[d]) byDay[d] = { date: d, cost_usd: 0, theoretical_cost_usd: 0, input_tokens: 0, output_tokens: 0 };
|
|
164
|
+
byDay[d].cost_usd += t.cost_usd;
|
|
165
|
+
byDay[d].theoretical_cost_usd += t.theoretical_cost_usd || 0;
|
|
166
|
+
byDay[d].input_tokens += t.input_tokens || 0;
|
|
167
|
+
byDay[d].output_tokens += t.output_tokens || 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fill in zeros for missing days
|
|
171
|
+
const now = new Date();
|
|
172
|
+
const series = [];
|
|
173
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
174
|
+
const d = new Date(now - i * MS_PER_DAY).toISOString().slice(0, 10);
|
|
175
|
+
series.push(byDay[d] || { date: d, cost_usd: 0, theoretical_cost_usd: 0, input_tokens: 0, output_tokens: 0 });
|
|
176
|
+
}
|
|
177
|
+
return series;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Compute the current 5-hour usage block (Anthropic rolling window).
|
|
182
|
+
* Returns usage within the last 5 hours from Claude turns only.
|
|
183
|
+
*/
|
|
184
|
+
export function buildBlockUsage() {
|
|
185
|
+
const fiveHoursMs = 5 * 3600000;
|
|
186
|
+
const cutoff = Date.now() - fiveHoursMs;
|
|
187
|
+
|
|
188
|
+
const raw = readAllTurns(1); // last 24h is enough
|
|
189
|
+
const turns = annotateCosts(raw.filter(t => t.platform === 'claude'));
|
|
190
|
+
|
|
191
|
+
const blockTurns = turns.filter(t => {
|
|
192
|
+
if (!t.timestamp) return false;
|
|
193
|
+
return new Date(t.timestamp).getTime() >= cutoff;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const usedCost = blockTurns.reduce((s, t) => s + t.cost_usd, 0);
|
|
197
|
+
const usedTheoretical = blockTurns.reduce((s, t) => s + (t.theoretical_cost_usd || 0), 0);
|
|
198
|
+
const usedTok = blockTurns.reduce((s, t) => s + (t.input_tokens || 0) + (t.output_tokens || 0), 0);
|
|
199
|
+
const start = new Date(cutoff).toISOString();
|
|
200
|
+
const end = new Date(Date.now()).toISOString();
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
start,
|
|
204
|
+
end,
|
|
205
|
+
window_minutes: 300,
|
|
206
|
+
used_tok: usedTok,
|
|
207
|
+
used_usd: usedCost,
|
|
208
|
+
used_usd_theoretical: usedTheoretical,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Top tools by token burn.
|
|
214
|
+
*/
|
|
215
|
+
export function buildTopTools(days, limit = 5) {
|
|
216
|
+
return buildBreakdown('tool_name', days).slice(0, limit);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Alias used by tests and CLI: getPeriodReport(days, observations?)
|
|
221
|
+
*/
|
|
222
|
+
export function getPeriodReport(days, observations = []) {
|
|
223
|
+
return buildCostReport(days, observations);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Returns the savings methodology doc for /api/savings/methodology.
|
|
228
|
+
*/
|
|
229
|
+
export { getSavingsMethodology };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IJFW cost/pricing.js
|
|
3
|
+
* Loads vendored model_prices.json and computes per-turn USD cost.
|
|
4
|
+
* Source: https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json
|
|
5
|
+
* Zero deps -- node:fs only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PRICES_PATH = join(__dirname, '../../data/model_prices.json');
|
|
14
|
+
|
|
15
|
+
let _prices = null;
|
|
16
|
+
|
|
17
|
+
function loadPrices() {
|
|
18
|
+
if (_prices) return _prices;
|
|
19
|
+
try {
|
|
20
|
+
const raw = readFileSync(PRICES_PATH, 'utf8');
|
|
21
|
+
const data = JSON.parse(raw);
|
|
22
|
+
_prices = data.models || data;
|
|
23
|
+
} catch {
|
|
24
|
+
_prices = {};
|
|
25
|
+
}
|
|
26
|
+
return _prices;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve price entry for a model id. Tries exact match, then common aliases.
|
|
31
|
+
* Returns { in, out, cache_create_5m, cache_create_1h, cache_read } all in USD/token.
|
|
32
|
+
*/
|
|
33
|
+
export function getPricing(modelId) {
|
|
34
|
+
const prices = loadPrices();
|
|
35
|
+
const id = (modelId || '').toLowerCase().trim();
|
|
36
|
+
|
|
37
|
+
// Exact match
|
|
38
|
+
let entry = prices[id] || prices[modelId];
|
|
39
|
+
|
|
40
|
+
// Fuzzy: strip date suffixes and try again
|
|
41
|
+
if (!entry) {
|
|
42
|
+
for (const key of Object.keys(prices)) {
|
|
43
|
+
if (key.toLowerCase().startsWith(id) || id.startsWith(key.toLowerCase())) {
|
|
44
|
+
entry = prices[key];
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Model family fallbacks for common Claude Code models
|
|
51
|
+
if (!entry) {
|
|
52
|
+
const fallbacks = {
|
|
53
|
+
'claude-opus-4': 'claude-4-opus-20250514',
|
|
54
|
+
'claude-sonnet-4': 'claude-4-sonnet-20250514',
|
|
55
|
+
'claude-haiku-4': 'claude-haiku-4-5',
|
|
56
|
+
'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
|
|
57
|
+
'claude-3-5-haiku': 'claude-3-5-haiku-20241022',
|
|
58
|
+
'claude-3-opus': 'claude-3-opus-20240229',
|
|
59
|
+
'gpt-5': 'gpt-4o',
|
|
60
|
+
'gpt-4o': 'gpt-4o',
|
|
61
|
+
'o3': 'o3',
|
|
62
|
+
'o4': 'o4-mini',
|
|
63
|
+
'gemini-2': 'gemini/gemini-2.0-flash',
|
|
64
|
+
'gemini-1.5': 'gemini/gemini-1.5-pro',
|
|
65
|
+
};
|
|
66
|
+
for (const [prefix, fallback] of Object.entries(fallbacks)) {
|
|
67
|
+
if (id.includes(prefix.toLowerCase())) {
|
|
68
|
+
entry = prices[fallback];
|
|
69
|
+
if (entry) break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!entry) {
|
|
75
|
+
// Unknown model: use claude-sonnet-4 rates as conservative estimate
|
|
76
|
+
entry = prices['claude-4-sonnet-20250514'] || {
|
|
77
|
+
input_cost_per_token: 3e-6,
|
|
78
|
+
output_cost_per_token: 1.5e-5,
|
|
79
|
+
cache_creation_input_token_cost: 3.75e-6,
|
|
80
|
+
cache_read_input_token_cost: 3e-7,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const inCost = entry.input_cost_per_token || 0;
|
|
85
|
+
const outCost = entry.output_cost_per_token || 0;
|
|
86
|
+
// cache_create_5m: 1.25x input price; cache_create_1h: 2.0x input price
|
|
87
|
+
// Use vendored values if present, else compute from multipliers
|
|
88
|
+
const cacheCreate5m = entry.cache_creation_input_token_cost ?? (inCost * 1.25);
|
|
89
|
+
const cacheCreate1h = inCost * 2.0; // always 2x; LiteLLM doesn't split this
|
|
90
|
+
const cacheRead = entry.cache_read_input_token_cost ?? (inCost * 0.1);
|
|
91
|
+
|
|
92
|
+
return { in: inCost, out: outCost, cache_create_5m: cacheCreate5m, cache_create_1h: cacheCreate1h, cache_read: cacheRead };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compute cost in USD for a single turn's usage object.
|
|
97
|
+
* usage: { input_tokens, output_tokens, cache_create_tokens_5m, cache_create_tokens_1h, cache_read_tokens }
|
|
98
|
+
* Uses correct split for 5m vs 1h cache creation (fixes ccusage bug #899).
|
|
99
|
+
*/
|
|
100
|
+
export function computeCost(modelId, usage) {
|
|
101
|
+
const p = getPricing(modelId);
|
|
102
|
+
const {
|
|
103
|
+
input_tokens = 0,
|
|
104
|
+
output_tokens = 0,
|
|
105
|
+
cache_create_tokens_5m = 0,
|
|
106
|
+
cache_create_tokens_1h = 0,
|
|
107
|
+
cache_read_tokens = 0,
|
|
108
|
+
} = usage || {};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
input_tokens * p.in +
|
|
112
|
+
output_tokens * p.out +
|
|
113
|
+
cache_create_tokens_5m * p.cache_create_5m +
|
|
114
|
+
cache_create_tokens_1h * p.cache_create_1h +
|
|
115
|
+
cache_read_tokens * p.cache_read
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Return the raw prices table for /api/prices transparency endpoint. */
|
|
120
|
+
export function getPricesTable() {
|
|
121
|
+
const prices = loadPrices();
|
|
122
|
+
return {
|
|
123
|
+
_source: 'https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json',
|
|
124
|
+
_refreshed: '2026-04-16',
|
|
125
|
+
formulas: {
|
|
126
|
+
turn_cost: 'input_tokens*in + output_tokens*out + cache_create_5m*cache_create_5m_price + cache_create_1h*cache_create_1h_price + cache_read*cache_read_price',
|
|
127
|
+
cache_savings: 'cache_read_tokens (measured, non-estimated only) * in_price * 0.9',
|
|
128
|
+
memory_savings: 'unique_first_recalls_per_session * 800_tokens * in_price',
|
|
129
|
+
trident_savings: '$5 per unique HIGH finding pre-ship (capped at 20/week)',
|
|
130
|
+
terse_savings: 'REMOVED -- prior 1.4x multiplier had no empirical basis',
|
|
131
|
+
},
|
|
132
|
+
models: prices,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IJFW cost/readers/claude.js
|
|
3
|
+
* Reads Claude Code JSONL session files from ~/.claude/projects/<project>/*.jsonl
|
|
4
|
+
* Extracts per-turn token usage. Never throws on corrupt lines -- logs and skips.
|
|
5
|
+
* Data approach adapted from ccusage (ryoppippi, MIT) and tokscale (junhoyeo, MIT).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
|
|
12
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
13
|
+
const TRANSCRIPT_SUMMARY = join(homedir(), '.ijfw', 'transcript-summary.json');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect Claude Code billing mode for the local user.
|
|
17
|
+
* ANTHROPIC_API_KEY in env is the unambiguous paid-API signal. Without it,
|
|
18
|
+
* Claude Code is using OAuth (Max/Pro/Team subscription) -- the OAuth token
|
|
19
|
+
* may live in ~/.claude/.credentials.json (Linux/Windows) or in the macOS
|
|
20
|
+
* Keychain, both of which Claude Code manages itself. Override with
|
|
21
|
+
* IJFW_BILLING_MODE=max|api.
|
|
22
|
+
*/
|
|
23
|
+
export function detectBillingMode() {
|
|
24
|
+
const override = (process.env.IJFW_BILLING_MODE || '').toLowerCase();
|
|
25
|
+
if (override === 'max' || override === 'api') return override;
|
|
26
|
+
if (process.env.ANTHROPIC_API_KEY) return 'api';
|
|
27
|
+
return 'max';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a per-session billing_mode map from ~/.ijfw/transcript-summary.json.
|
|
32
|
+
* The transcript parser stamps the mode at parse time (first-parse-wins),
|
|
33
|
+
* so this preserves historical billing context across mode switches. Sessions
|
|
34
|
+
* that have not yet been parsed fall back to current env detection.
|
|
35
|
+
*/
|
|
36
|
+
function loadSessionBillingMap() {
|
|
37
|
+
if (!existsSync(TRANSCRIPT_SUMMARY)) return null;
|
|
38
|
+
let data;
|
|
39
|
+
try { data = JSON.parse(readFileSync(TRANSCRIPT_SUMMARY, 'utf8')); }
|
|
40
|
+
catch { return null; }
|
|
41
|
+
const map = new Map();
|
|
42
|
+
const projects = data && data.projects;
|
|
43
|
+
if (!projects || typeof projects !== 'object') return map;
|
|
44
|
+
for (const proj of Object.values(projects)) {
|
|
45
|
+
if (!proj || !Array.isArray(proj.sessions)) continue;
|
|
46
|
+
for (const sess of proj.sessions) {
|
|
47
|
+
if (sess && sess.file && sess.billingMode) {
|
|
48
|
+
map.set(sess.file.replace(/\.jsonl$/, ''), sess.billingMode);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return map;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walk ~/.claude/projects/<project>/<session>.jsonl and extract usage turns.
|
|
57
|
+
* Returns array of turn objects.
|
|
58
|
+
*/
|
|
59
|
+
export function readClaudeSessions(projectsDir = CLAUDE_PROJECTS_DIR) {
|
|
60
|
+
if (!existsSync(projectsDir)) return [];
|
|
61
|
+
|
|
62
|
+
// Per-session billing mode preserves historical context across env-mode
|
|
63
|
+
// switches; current env detection is the fallback for unparsed sessions.
|
|
64
|
+
const sessionBillingMap = loadSessionBillingMap();
|
|
65
|
+
const fallbackMode = detectBillingMode();
|
|
66
|
+
const turns = [];
|
|
67
|
+
let projectDirs;
|
|
68
|
+
try {
|
|
69
|
+
projectDirs = readdirSync(projectsDir);
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const projectName of projectDirs) {
|
|
75
|
+
const projectPath = join(projectsDir, projectName);
|
|
76
|
+
let stat;
|
|
77
|
+
try { stat = statSync(projectPath); } catch { continue; }
|
|
78
|
+
if (!stat.isDirectory()) continue;
|
|
79
|
+
|
|
80
|
+
let files;
|
|
81
|
+
try { files = readdirSync(projectPath); } catch { continue; }
|
|
82
|
+
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
85
|
+
const filePath = join(projectPath, file);
|
|
86
|
+
const sessionId = file.replace('.jsonl', '');
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
try {
|
|
92
|
+
const record = JSON.parse(line);
|
|
93
|
+
const turn = extractClaudeTurn(record, sessionId, projectName);
|
|
94
|
+
if (turn) {
|
|
95
|
+
turn.billing_mode = (sessionBillingMap && sessionBillingMap.get(sessionId)) || fallbackMode;
|
|
96
|
+
turns.push(turn);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// corrupt line -- skip
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// unreadable file -- skip
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Also walk subagents/ subdirectory
|
|
108
|
+
const subagentsDir = join(projectPath, 'subagents');
|
|
109
|
+
if (existsSync(subagentsDir)) {
|
|
110
|
+
let subfiles;
|
|
111
|
+
try { subfiles = readdirSync(subagentsDir); } catch { subfiles = []; }
|
|
112
|
+
for (const file of subfiles) {
|
|
113
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
114
|
+
const filePath = join(subagentsDir, file);
|
|
115
|
+
const sessionId = file.replace('.jsonl', '');
|
|
116
|
+
try {
|
|
117
|
+
const lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
try {
|
|
120
|
+
const record = JSON.parse(line);
|
|
121
|
+
const turn = extractClaudeTurn(record, sessionId, projectName);
|
|
122
|
+
if (turn) {
|
|
123
|
+
turn.billing_mode = (sessionBillingMap && sessionBillingMap.get(sessionId)) || fallbackMode;
|
|
124
|
+
turns.push(turn);
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return turns;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractClaudeTurn(record, sessionId, project) {
|
|
137
|
+
// Claude Code JSONL: assistant messages have message.usage
|
|
138
|
+
const msg = record.message;
|
|
139
|
+
if (!msg || msg.role !== 'assistant') return null;
|
|
140
|
+
|
|
141
|
+
const usage = msg.usage;
|
|
142
|
+
if (!usage) return null;
|
|
143
|
+
|
|
144
|
+
const inputTokens = usage.input_tokens || 0;
|
|
145
|
+
const outputTokens = usage.output_tokens || 0;
|
|
146
|
+
const cacheRead = usage.cache_read_input_tokens || 0;
|
|
147
|
+
|
|
148
|
+
// Claude Code 3.7+ splits cache creation into 5m and 1h buckets
|
|
149
|
+
const cacheCreate5m = (usage.cache_creation && usage.cache_creation.ephemeral_5m_input_tokens) ||
|
|
150
|
+
(usage.cache_creation_input_tokens || 0); // fallback: pre-3.7
|
|
151
|
+
const cacheCreate1h = (usage.cache_creation && usage.cache_creation.ephemeral_1h_input_tokens) || 0;
|
|
152
|
+
|
|
153
|
+
if (!inputTokens && !outputTokens && !cacheRead && !cacheCreate5m && !cacheCreate1h) return null;
|
|
154
|
+
|
|
155
|
+
// Find tool_name from the content blocks if present
|
|
156
|
+
let toolName = null;
|
|
157
|
+
if (Array.isArray(msg.content)) {
|
|
158
|
+
for (const block of msg.content) {
|
|
159
|
+
if (block && block.type === 'tool_use') {
|
|
160
|
+
toolName = block.name;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
platform: 'claude',
|
|
168
|
+
session_id: sessionId,
|
|
169
|
+
project,
|
|
170
|
+
timestamp: record.timestamp || null,
|
|
171
|
+
model: msg.model || 'claude-unknown',
|
|
172
|
+
input_tokens: inputTokens,
|
|
173
|
+
output_tokens: outputTokens,
|
|
174
|
+
cache_create_tokens_5m: cacheCreate5m,
|
|
175
|
+
cache_create_tokens_1h: cacheCreate1h,
|
|
176
|
+
cache_read_tokens: cacheRead,
|
|
177
|
+
tool_name: toolName,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IJFW cost/readers/codex.js
|
|
3
|
+
* Reads Codex CLI JSONL session files from ~/.codex/sessions/**\/*.jsonl
|
|
4
|
+
* Codex uses event_msg/token_count for rate-limit tracking (not per-turn billing).
|
|
5
|
+
* For cost attribution we use session_meta (model) + response_item messages
|
|
6
|
+
* and estimate tokens from content length when explicit counts unavailable.
|
|
7
|
+
* Data approach adapted from tokscale (junhoyeo, MIT).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
|
|
14
|
+
const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
|
|
15
|
+
|
|
16
|
+
export function readCodexSessions(sessionsDir = CODEX_SESSIONS_DIR) {
|
|
17
|
+
if (!existsSync(sessionsDir)) return [];
|
|
18
|
+
const turns = [];
|
|
19
|
+
walkDir(sessionsDir, turns);
|
|
20
|
+
return turns;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function walkDir(dir, turns) {
|
|
24
|
+
let entries;
|
|
25
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = join(dir, entry);
|
|
29
|
+
let stat;
|
|
30
|
+
try { stat = statSync(fullPath); } catch { continue; }
|
|
31
|
+
|
|
32
|
+
if (stat.isDirectory()) {
|
|
33
|
+
walkDir(fullPath, turns);
|
|
34
|
+
} else if (entry.endsWith('.jsonl')) {
|
|
35
|
+
processCodexFile(fullPath, turns);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function processCodexFile(filePath, turns) {
|
|
41
|
+
let lines;
|
|
42
|
+
try {
|
|
43
|
+
lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Collect session metadata first
|
|
49
|
+
let sessionMeta = null;
|
|
50
|
+
let sessionId = null;
|
|
51
|
+
let model = 'gpt-5'; // codex default
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
try {
|
|
55
|
+
const record = JSON.parse(line);
|
|
56
|
+
if (record.type === 'session_meta') {
|
|
57
|
+
sessionMeta = record.payload || {};
|
|
58
|
+
sessionId = sessionMeta.id || null;
|
|
59
|
+
model = sessionMeta.collaboration_mode?.settings?.model ||
|
|
60
|
+
sessionMeta.model || 'gpt-5';
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!sessionId) {
|
|
66
|
+
// Derive session id from filename
|
|
67
|
+
const base = filePath.split('/').pop().replace('.jsonl', '');
|
|
68
|
+
sessionId = base;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Extract timestamp from session path (YYYY/MM/DD/filename)
|
|
72
|
+
const dateParts = filePath.match(/(\d{4})\/(\d{2})\/(\d{2})/);
|
|
73
|
+
const datePrefix = dateParts ? `${dateParts[1]}-${dateParts[2]}-${dateParts[3]}` : null;
|
|
74
|
+
|
|
75
|
+
// Accumulate per-session totals from response_item/message content
|
|
76
|
+
// Codex does not expose per-turn token counts in JSONL; we estimate from chars
|
|
77
|
+
let totalInputChars = 0;
|
|
78
|
+
let totalOutputChars = 0;
|
|
79
|
+
let lastTimestamp = datePrefix ? datePrefix + 'T12:00:00.000Z' : null;
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
try {
|
|
83
|
+
const record = JSON.parse(line);
|
|
84
|
+
if (record.timestamp) lastTimestamp = record.timestamp;
|
|
85
|
+
|
|
86
|
+
if (record.type === 'response_item' && record.payload) {
|
|
87
|
+
const payload = record.payload;
|
|
88
|
+
const role = payload.role;
|
|
89
|
+
const content = payload.content;
|
|
90
|
+
if (!Array.isArray(content)) continue;
|
|
91
|
+
|
|
92
|
+
let chars = 0;
|
|
93
|
+
for (const block of content) {
|
|
94
|
+
if (block && block.text) chars += block.text.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (role === 'user' || role === 'developer') {
|
|
98
|
+
totalInputChars += chars;
|
|
99
|
+
} else {
|
|
100
|
+
totalOutputChars += chars;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Estimate tokens: 1 token ~= 4 chars (GPT-era heuristic)
|
|
107
|
+
const inputTokens = Math.ceil(totalInputChars / 4);
|
|
108
|
+
const outputTokens = Math.ceil(totalOutputChars / 4);
|
|
109
|
+
|
|
110
|
+
if (!inputTokens && !outputTokens) return;
|
|
111
|
+
|
|
112
|
+
// Codex does not expose per-turn token counts or cache metrics in JSONL.
|
|
113
|
+
// Tokens are estimated from content length (1 token ~= 4 chars, GPT-era heuristic).
|
|
114
|
+
// cache_read_tokens is NOT included -- we cannot distinguish cached vs fresh reads
|
|
115
|
+
// from Codex JSONL, and fabricating a cache_read number would inflate savings.
|
|
116
|
+
turns.push({
|
|
117
|
+
platform: 'codex',
|
|
118
|
+
session_id: sessionId,
|
|
119
|
+
project: sessionMeta?.git?.repository_url?.split('/').pop() || null,
|
|
120
|
+
timestamp: lastTimestamp,
|
|
121
|
+
model,
|
|
122
|
+
input_tokens: inputTokens,
|
|
123
|
+
output_tokens: outputTokens,
|
|
124
|
+
cache_create_tokens_5m: 0,
|
|
125
|
+
cache_create_tokens_1h: 0,
|
|
126
|
+
cache_read_tokens: 0, // not available from Codex JSONL; excluded from savings
|
|
127
|
+
tool_name: null,
|
|
128
|
+
estimated: true, // tokens derived from char heuristic, not actual API usage counts
|
|
129
|
+
estimatedNote: 'Codex CLI does not log per-turn token counts locally. Tokens estimated from content length.',
|
|
130
|
+
});
|
|
131
|
+
}
|