dual-brain 3.0.1 → 3.2.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.
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * summary-checkpoint.mjs — Fast derived state for the hot path.
4
+ *
5
+ * Maintains a summary file (usage-summary-YYYY-MM-DD.json) that hooks
6
+ * can read in O(1) instead of scanning the full JSONL log.
7
+ *
8
+ * The summary is rebuilt from JSONL truth if missing or corrupt.
9
+ *
10
+ * Exported API:
11
+ * readSummary(date?) → current summary object
12
+ * updateSummary(newEntry) → incrementally update summary with one entry
13
+ * rebuildSummary(date?) → full rebuild from JSONL
14
+ * getRecentPromptHashes() → last 10min of prompt hashes (for dupe detection)
15
+ * getPressureBuckets() → provider/tier call counts for rolling window
16
+ * getTokenAverages() → moving averages of actual tokens by tier
17
+ */
18
+
19
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
20
+ import { dirname, join } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
25
+ function summaryPath(date) {
26
+ const d = date || new Date().toISOString().slice(0, 10);
27
+ return join(__dirname, `usage-summary-${d}.json`);
28
+ }
29
+
30
+ function usagePath(date) {
31
+ const d = date || new Date().toISOString().slice(0, 10);
32
+ return join(__dirname, `usage-${d}.jsonl`);
33
+ }
34
+
35
+ function emptySummary() {
36
+ return {
37
+ version: 1,
38
+ date: new Date().toISOString().slice(0, 10),
39
+ updated_at: new Date().toISOString(),
40
+ last_offset: 0,
41
+
42
+ totals: {
43
+ calls: 0,
44
+ cost_estimate: 0,
45
+ by_tier: {},
46
+ by_provider: {},
47
+ by_model: {},
48
+ },
49
+
50
+ pressure: {
51
+ claude: { think: [], execute: [], search: [] },
52
+ openai: { think: [], execute: [], search: [] },
53
+ },
54
+
55
+ recent_hashes: [],
56
+
57
+ token_averages: {},
58
+
59
+ codex_latencies: [],
60
+ };
61
+ }
62
+
63
+ const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
64
+
65
+ function atomicWrite(path, data) {
66
+ const tmp = path + '.tmp.' + process.pid;
67
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
68
+ renameSync(tmp, path);
69
+ }
70
+
71
+ function readSummary(date) {
72
+ const path = summaryPath(date);
73
+ try {
74
+ const data = JSON.parse(readFileSync(path, 'utf8'));
75
+ if (data.version === 1) return data;
76
+ } catch {}
77
+ return rebuildSummary(date);
78
+ }
79
+
80
+ function rebuildSummary(date) {
81
+ const d = date || new Date().toISOString().slice(0, 10);
82
+ const logPath = usagePath(d);
83
+ const summary = emptySummary();
84
+ summary.date = d;
85
+
86
+ if (!existsSync(logPath)) {
87
+ atomicWrite(summaryPath(d), summary);
88
+ return summary;
89
+ }
90
+
91
+ let raw;
92
+ try { raw = readFileSync(logPath, 'utf8'); } catch { return summary; }
93
+
94
+ const lines = raw.split('\n').filter(Boolean);
95
+ for (const line of lines) {
96
+ try {
97
+ const entry = JSON.parse(line);
98
+ applyEntry(summary, entry);
99
+ } catch {}
100
+ }
101
+
102
+ summary.last_offset = Buffer.byteLength(raw, 'utf8');
103
+ summary.updated_at = new Date().toISOString();
104
+ atomicWrite(summaryPath(d), summary);
105
+ return summary;
106
+ }
107
+
108
+ function applyEntry(summary, entry) {
109
+ const tier = entry.tier || 'execute';
110
+ const provider = entry.provider || 'claude';
111
+ const model = entry.model || 'unknown';
112
+ const cost = COST_PER_CALL[tier] || COST_PER_CALL.execute;
113
+
114
+ summary.totals.calls++;
115
+ summary.totals.cost_estimate += cost;
116
+
117
+ summary.totals.by_tier[tier] = (summary.totals.by_tier[tier] || 0) + 1;
118
+ summary.totals.by_provider[provider] = (summary.totals.by_provider[provider] || 0) + 1;
119
+ summary.totals.by_model[model] = (summary.totals.by_model[model] || 0) + 1;
120
+
121
+ // Pressure: store timestamps for rolling window lookups
122
+ const ts = entry.timestamp || new Date().toISOString();
123
+ if (summary.pressure[provider]?.[tier]) {
124
+ summary.pressure[provider][tier].push(ts);
125
+ // Keep only last 5 hours of timestamps to bound size
126
+ const cutoff = Date.now() - 5 * 60 * 60 * 1000;
127
+ summary.pressure[provider][tier] = summary.pressure[provider][tier].filter(
128
+ t => Date.parse(t) >= cutoff
129
+ );
130
+ }
131
+
132
+ // Recent prompt hashes (for duplicate detection)
133
+ if (entry.type === 'tier_recommendation' && entry.prompt_hash) {
134
+ summary.recent_hashes.push({ hash: entry.prompt_hash, ts });
135
+ const tenMinAgo = Date.now() - 10 * 60 * 1000;
136
+ summary.recent_hashes = summary.recent_hashes.filter(
137
+ h => Date.parse(h.ts) >= tenMinAgo
138
+ );
139
+ }
140
+
141
+ // Token moving averages
142
+ if (entry.input_tokens != null && entry.output_tokens != null) {
143
+ const key = `${provider}:${tier}`;
144
+ if (!summary.token_averages[key]) {
145
+ summary.token_averages[key] = { count: 0, avg_input: 0, avg_output: 0 };
146
+ }
147
+ const avg = summary.token_averages[key];
148
+ avg.count++;
149
+ avg.avg_input += (entry.input_tokens - avg.avg_input) / avg.count;
150
+ avg.avg_output += (entry.output_tokens - avg.avg_output) / avg.count;
151
+ }
152
+
153
+ // Codex latencies
154
+ if (entry.codex_startup_ms != null) {
155
+ summary.codex_latencies.push({
156
+ startup_ms: entry.codex_startup_ms,
157
+ total_ms: entry.codex_total_ms || null,
158
+ model: model,
159
+ ts,
160
+ });
161
+ // Keep last 50
162
+ if (summary.codex_latencies.length > 50) {
163
+ summary.codex_latencies = summary.codex_latencies.slice(-50);
164
+ }
165
+ }
166
+ }
167
+
168
+ function updateSummary(newEntry, date) {
169
+ const summary = readSummary(date);
170
+ applyEntry(summary, newEntry);
171
+ summary.updated_at = new Date().toISOString();
172
+ atomicWrite(summaryPath(date), summary);
173
+ return summary;
174
+ }
175
+
176
+ function getRecentPromptHashes(date) {
177
+ const summary = readSummary(date);
178
+ const tenMinAgo = Date.now() - 10 * 60 * 1000;
179
+ return summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
180
+ }
181
+
182
+ function getPressureBuckets(date) {
183
+ const summary = readSummary(date);
184
+ const cutoff = Date.now() - 5 * 60 * 60 * 1000;
185
+ const result = {};
186
+
187
+ for (const provider of ['claude', 'openai']) {
188
+ result[provider] = {};
189
+ for (const tier of ['think', 'execute', 'search']) {
190
+ const timestamps = summary.pressure[provider]?.[tier] || [];
191
+ result[provider][tier] = timestamps.filter(t => Date.parse(t) >= cutoff).length;
192
+ }
193
+ }
194
+ return result;
195
+ }
196
+
197
+ function getTokenAverages(date) {
198
+ const summary = readSummary(date);
199
+ return summary.token_averages;
200
+ }
201
+
202
+ function getAdaptiveCodexThreshold(date) {
203
+ const summary = readSummary(date);
204
+ const latencies = summary.codex_latencies || [];
205
+ if (latencies.length < 5) return { threshold_ms: 180_000, confidence: 'low', samples: latencies.length };
206
+
207
+ const startups = latencies.map(l => l.startup_ms).filter(Boolean).sort((a, b) => a - b);
208
+ if (startups.length < 3) return { threshold_ms: 180_000, confidence: 'low', samples: startups.length };
209
+
210
+ const p75idx = Math.floor(startups.length * 0.75);
211
+ const p75 = startups[p75idx];
212
+ const threshold = Math.max(90_000, p75 * 4);
213
+
214
+ return {
215
+ threshold_ms: Math.round(threshold),
216
+ p75_startup_ms: Math.round(p75),
217
+ confidence: startups.length >= 20 ? 'high' : 'medium',
218
+ samples: startups.length,
219
+ };
220
+ }
221
+
222
+ export {
223
+ readSummary,
224
+ updateSummary,
225
+ rebuildSummary,
226
+ getRecentPromptHashes,
227
+ getPressureBuckets,
228
+ getTokenAverages,
229
+ getAdaptiveCodexThreshold,
230
+ atomicWrite,
231
+ };