@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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. 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
+ }