brain-dev 0.1.2 → 0.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.
- package/bin/brain-tools.cjs +17 -1
- package/bin/lib/bridge.cjs +47 -0
- package/bin/lib/commands/auto.cjs +337 -0
- package/bin/lib/commands/dashboard.cjs +177 -0
- package/bin/lib/commands/execute.cjs +18 -0
- package/bin/lib/commands/progress.cjs +37 -1
- package/bin/lib/commands/recover.cjs +155 -0
- package/bin/lib/commands/verify.cjs +15 -5
- package/bin/lib/commands.cjs +18 -0
- package/bin/lib/config.cjs +23 -2
- package/bin/lib/context.cjs +397 -0
- package/bin/lib/cost.cjs +273 -0
- package/bin/lib/dashboard-collector.cjs +98 -0
- package/bin/lib/dashboard-server.cjs +33 -0
- package/bin/lib/hook-dispatcher.cjs +99 -0
- package/bin/lib/lock.cjs +163 -0
- package/bin/lib/logger.cjs +18 -0
- package/bin/lib/recovery.cjs +468 -0
- package/bin/lib/security.cjs +16 -2
- package/bin/lib/state.cjs +118 -8
- package/bin/lib/stuck.cjs +269 -0
- package/bin/lib/tokens.cjs +32 -0
- package/commands/brain/auto.md +31 -0
- package/commands/brain/dashboard.md +18 -0
- package/commands/brain/recover.md +19 -0
- package/hooks/bootstrap.sh +15 -1
- package/package.json +1 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { estimateTokens } = require('./tokens.cjs');
|
|
6
|
+
|
|
7
|
+
// Token budgets per agent type
|
|
8
|
+
const CONTEXT_BUDGETS = {
|
|
9
|
+
executor: 80000,
|
|
10
|
+
planner: 60000,
|
|
11
|
+
verifier: 40000,
|
|
12
|
+
debugger: 60000,
|
|
13
|
+
researcher: 30000
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Priority tiers for context files
|
|
17
|
+
const PRIORITY_TIERS = {
|
|
18
|
+
P0_PLAN: 0,
|
|
19
|
+
P1_STATE: 1,
|
|
20
|
+
P2_PHASE_CONTEXT: 2,
|
|
21
|
+
P3_PRIOR_SUMMARIES: 3,
|
|
22
|
+
P4_RESEARCH: 4,
|
|
23
|
+
P5_ARCHITECTURE: 5,
|
|
24
|
+
P6_ADRS: 6,
|
|
25
|
+
P7_ROADMAP: 7
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find the phase directory for a given phase number.
|
|
30
|
+
* Mirrors the pattern from stuck.cjs.
|
|
31
|
+
* @param {string} brainDir
|
|
32
|
+
* @param {number} phaseNumber
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
function findPhaseDir(brainDir, phaseNumber) {
|
|
36
|
+
const phasesDir = path.join(brainDir, 'phases');
|
|
37
|
+
if (!fs.existsSync(phasesDir)) return null;
|
|
38
|
+
const padded = String(phaseNumber).padStart(2, '0');
|
|
39
|
+
const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(padded + '-'));
|
|
40
|
+
return dirs.length > 0 ? path.join(phasesDir, dirs[0]) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Safely read a file, returning empty string on any failure.
|
|
45
|
+
* @param {string} filePath
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function safeRead(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Condense a SUMMARY-NN.md to ~80 tokens.
|
|
58
|
+
* Extracts: status line, key files, key decisions, provides, patterns.
|
|
59
|
+
* @param {string} summaryContent
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function condenseSummary(summaryContent) {
|
|
63
|
+
if (!summaryContent || typeof summaryContent !== 'string') return '';
|
|
64
|
+
|
|
65
|
+
const lines = summaryContent.split('\n');
|
|
66
|
+
const parts = [];
|
|
67
|
+
|
|
68
|
+
// Extract status from frontmatter or first heading
|
|
69
|
+
const statusMatch = summaryContent.match(/status:\s*(.+)/i);
|
|
70
|
+
if (statusMatch) parts.push(`Status: ${statusMatch[1].trim()}`);
|
|
71
|
+
|
|
72
|
+
// Extract key files (lines with file paths)
|
|
73
|
+
const fileLines = lines.filter(l => l.match(/^\s*-\s+.*\.(js|ts|cjs|mjs|json|md|sh|yaml|yml)\b/));
|
|
74
|
+
if (fileLines.length > 0) {
|
|
75
|
+
parts.push('Files: ' + fileLines.slice(0, 5).map(l => l.trim().replace(/^-\s*/, '')).join(', '));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Extract decisions (lines under ## Decisions or ## Key Decisions)
|
|
79
|
+
let inDecisions = false;
|
|
80
|
+
const decisions = [];
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.match(/^##\s*(Key\s+)?Decisions/i)) { inDecisions = true; continue; }
|
|
83
|
+
if (inDecisions && line.startsWith('## ')) { inDecisions = false; continue; }
|
|
84
|
+
if (inDecisions && line.trim().startsWith('- ')) {
|
|
85
|
+
decisions.push(line.trim());
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (decisions.length > 0) {
|
|
89
|
+
parts.push('Decisions: ' + decisions.slice(0, 3).join('; '));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Extract provides/patterns
|
|
93
|
+
const providesMatch = summaryContent.match(/provides:\s*(.+)/i);
|
|
94
|
+
if (providesMatch) parts.push(`Provides: ${providesMatch[1].trim()}`);
|
|
95
|
+
|
|
96
|
+
const patternsMatch = summaryContent.match(/patterns?:\s*(.+)/i);
|
|
97
|
+
if (patternsMatch) parts.push(`Patterns: ${patternsMatch[1].trim()}`);
|
|
98
|
+
|
|
99
|
+
const condensed = parts.join(' | ');
|
|
100
|
+
// Truncate to roughly 80 tokens if needed (~320 chars)
|
|
101
|
+
if (condensed.length > 320) {
|
|
102
|
+
return condensed.slice(0, 317) + '...';
|
|
103
|
+
}
|
|
104
|
+
return condensed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Collect and condense SUMMARY-NN.md files for plans before planNum.
|
|
109
|
+
* @param {string} phaseDir
|
|
110
|
+
* @param {number} upToPlan - Exclusive upper bound (plans 1..upToPlan-1)
|
|
111
|
+
* @returns {string} Condensed summaries block
|
|
112
|
+
*/
|
|
113
|
+
function collectPriorSummaries(phaseDir, upToPlan) {
|
|
114
|
+
if (!phaseDir || upToPlan <= 1) return '';
|
|
115
|
+
|
|
116
|
+
const summaries = [];
|
|
117
|
+
for (let i = 1; i < upToPlan; i++) {
|
|
118
|
+
const padded = String(i).padStart(2, '0');
|
|
119
|
+
const summaryPath = path.join(phaseDir, `SUMMARY-${padded}.md`);
|
|
120
|
+
const content = safeRead(summaryPath);
|
|
121
|
+
if (content) {
|
|
122
|
+
const condensed = condenseSummary(content);
|
|
123
|
+
if (condensed) {
|
|
124
|
+
summaries.push(`Plan ${padded}: ${condensed}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return summaries.join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build a priority-ordered manifest of context files to include.
|
|
133
|
+
* @param {string} brainDir
|
|
134
|
+
* @param {string} phaseDir
|
|
135
|
+
* @param {number} planNum
|
|
136
|
+
* @returns {Array<{ tier: number, label: string, path: string, required: boolean }>}
|
|
137
|
+
*/
|
|
138
|
+
function buildContextManifest(brainDir, phaseDir, planNum) {
|
|
139
|
+
const manifest = [];
|
|
140
|
+
const padded = String(planNum).padStart(2, '0');
|
|
141
|
+
|
|
142
|
+
// P0: Plan content (required)
|
|
143
|
+
if (phaseDir) {
|
|
144
|
+
manifest.push({
|
|
145
|
+
tier: PRIORITY_TIERS.P0_PLAN,
|
|
146
|
+
label: 'Plan',
|
|
147
|
+
path: path.join(phaseDir, `PLAN-${padded}.md`),
|
|
148
|
+
required: true
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// P1: STATE.md excerpt (required)
|
|
153
|
+
manifest.push({
|
|
154
|
+
tier: PRIORITY_TIERS.P1_STATE,
|
|
155
|
+
label: 'Project State',
|
|
156
|
+
path: path.join(brainDir, 'STATE.md'),
|
|
157
|
+
required: true
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// P2: Phase CONTEXT.md decisions (required)
|
|
161
|
+
if (phaseDir) {
|
|
162
|
+
manifest.push({
|
|
163
|
+
tier: PRIORITY_TIERS.P2_PHASE_CONTEXT,
|
|
164
|
+
label: 'Phase Decisions',
|
|
165
|
+
path: path.join(phaseDir, 'CONTEXT.md'),
|
|
166
|
+
required: true
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// P3: Prior summaries (required, synthetic - handled separately)
|
|
171
|
+
// Represented as a placeholder; actual content assembled by collectPriorSummaries
|
|
172
|
+
manifest.push({
|
|
173
|
+
tier: PRIORITY_TIERS.P3_PRIOR_SUMMARIES,
|
|
174
|
+
label: 'Prior Plan Results',
|
|
175
|
+
path: '__SYNTHETIC_PRIOR_SUMMARIES__',
|
|
176
|
+
required: true
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// P4: Phase RESEARCH.md
|
|
180
|
+
if (phaseDir) {
|
|
181
|
+
manifest.push({
|
|
182
|
+
tier: PRIORITY_TIERS.P4_RESEARCH,
|
|
183
|
+
label: 'Research',
|
|
184
|
+
path: path.join(phaseDir, 'RESEARCH.md'),
|
|
185
|
+
required: false
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// P5: codebase/ARCHITECTURE.md
|
|
190
|
+
manifest.push({
|
|
191
|
+
tier: PRIORITY_TIERS.P5_ARCHITECTURE,
|
|
192
|
+
label: 'Architecture',
|
|
193
|
+
path: path.join(brainDir, 'codebase', 'ARCHITECTURE.md'),
|
|
194
|
+
required: false
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// P6: Relevant ADRs
|
|
198
|
+
const adrDir = path.join(brainDir, 'decisions');
|
|
199
|
+
try {
|
|
200
|
+
if (fs.existsSync(adrDir)) {
|
|
201
|
+
const adrs = fs.readdirSync(adrDir)
|
|
202
|
+
.filter(f => f.endsWith('.md'))
|
|
203
|
+
.sort();
|
|
204
|
+
for (const adr of adrs) {
|
|
205
|
+
manifest.push({
|
|
206
|
+
tier: PRIORITY_TIERS.P6_ADRS,
|
|
207
|
+
label: `ADR: ${adr}`,
|
|
208
|
+
path: path.join(adrDir, adr),
|
|
209
|
+
required: false
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch { /* ADR dir may not exist */ }
|
|
214
|
+
|
|
215
|
+
// P7: ROADMAP.md excerpt
|
|
216
|
+
manifest.push({
|
|
217
|
+
tier: PRIORITY_TIERS.P7_ROADMAP,
|
|
218
|
+
label: 'Roadmap',
|
|
219
|
+
path: path.join(brainDir, 'ROADMAP.md'),
|
|
220
|
+
required: false
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return manifest;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Calculate the available context token budget for an agent.
|
|
228
|
+
* Subtracts a complexity overhead from the base budget.
|
|
229
|
+
* @param {string} agentType - executor|planner|verifier|debugger|researcher
|
|
230
|
+
* @param {string} planContent - Raw plan content for complexity estimation
|
|
231
|
+
* @param {object} [brainConfig] - Optional brain config with budget overrides
|
|
232
|
+
* @returns {number} Available token budget
|
|
233
|
+
*/
|
|
234
|
+
function calculateContextBudget(agentType, planContent, brainConfig) {
|
|
235
|
+
const baseBudget = (brainConfig && brainConfig.context_budgets && brainConfig.context_budgets[agentType])
|
|
236
|
+
|| CONTEXT_BUDGETS[agentType]
|
|
237
|
+
|| CONTEXT_BUDGETS.executor;
|
|
238
|
+
|
|
239
|
+
// Reserve tokens proportional to plan complexity
|
|
240
|
+
// Larger plans need more room for the agent's own reasoning
|
|
241
|
+
const planTokens = estimateTokens(planContent || '');
|
|
242
|
+
const complexityOverhead = Math.min(Math.round(planTokens * 0.1), 5000);
|
|
243
|
+
|
|
244
|
+
return baseBudget - complexityOverhead;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Detect whether any manifest files have been modified after a reference timestamp.
|
|
249
|
+
* @param {Array<{ path: string }>} manifest
|
|
250
|
+
* @param {string} referenceTimestamp - ISO timestamp of assembly time
|
|
251
|
+
* @returns {{ stale: boolean, staleFiles: string[] }}
|
|
252
|
+
*/
|
|
253
|
+
function detectStaleContext(manifest, referenceTimestamp) {
|
|
254
|
+
const refTime = new Date(referenceTimestamp).getTime();
|
|
255
|
+
const staleFiles = [];
|
|
256
|
+
|
|
257
|
+
for (const entry of manifest) {
|
|
258
|
+
if (entry.path === '__SYNTHETIC_PRIOR_SUMMARIES__') continue;
|
|
259
|
+
try {
|
|
260
|
+
const stat = fs.statSync(entry.path);
|
|
261
|
+
if (stat.mtime.getTime() > refTime) {
|
|
262
|
+
staleFiles.push(entry.path);
|
|
263
|
+
}
|
|
264
|
+
} catch { /* file may not exist */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { stale: staleFiles.length > 0, staleFiles };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Main context assembly function.
|
|
272
|
+
* Reads files in priority order within token budget, returns assembled context block.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
275
|
+
* @param {string} phaseDir - Path to the current phase directory (or null)
|
|
276
|
+
* @param {number} planNum - Current plan number
|
|
277
|
+
* @param {object} [opts] - Options
|
|
278
|
+
* @param {string} [opts.agentType='executor'] - Agent type for budget calculation
|
|
279
|
+
* @param {object} [opts.brainConfig] - Brain config with optional budget overrides
|
|
280
|
+
* @returns {{ context: string, manifest: Array, tokenEstimate: number, truncated: boolean }}
|
|
281
|
+
*/
|
|
282
|
+
function assembleTaskContext(brainDir, phaseDir, planNum, opts) {
|
|
283
|
+
const options = opts || {};
|
|
284
|
+
const agentType = options.agentType || 'executor';
|
|
285
|
+
const brainConfig = options.brainConfig || null;
|
|
286
|
+
|
|
287
|
+
const manifest = buildContextManifest(brainDir, phaseDir, planNum);
|
|
288
|
+
|
|
289
|
+
// Read plan content first to calculate budget
|
|
290
|
+
const planEntry = manifest.find(e => e.tier === PRIORITY_TIERS.P0_PLAN);
|
|
291
|
+
const planContent = planEntry ? safeRead(planEntry.path) : '';
|
|
292
|
+
const budget = calculateContextBudget(agentType, planContent, brainConfig);
|
|
293
|
+
|
|
294
|
+
const sections = [];
|
|
295
|
+
let totalTokens = 0;
|
|
296
|
+
let truncated = false;
|
|
297
|
+
const includedFiles = [];
|
|
298
|
+
|
|
299
|
+
for (const entry of manifest) {
|
|
300
|
+
let content;
|
|
301
|
+
let sectionHeader;
|
|
302
|
+
|
|
303
|
+
if (entry.path === '__SYNTHETIC_PRIOR_SUMMARIES__') {
|
|
304
|
+
content = collectPriorSummaries(phaseDir, planNum);
|
|
305
|
+
sectionHeader = '## Prior Plan Results';
|
|
306
|
+
} else {
|
|
307
|
+
content = safeRead(entry.path);
|
|
308
|
+
if (entry.tier === PRIORITY_TIERS.P0_PLAN) {
|
|
309
|
+
sectionHeader = '## Current Plan';
|
|
310
|
+
} else if (entry.tier === PRIORITY_TIERS.P1_STATE) {
|
|
311
|
+
sectionHeader = '## Project State';
|
|
312
|
+
} else if (entry.tier === PRIORITY_TIERS.P2_PHASE_CONTEXT) {
|
|
313
|
+
sectionHeader = '## Phase Decisions';
|
|
314
|
+
} else if (entry.tier === PRIORITY_TIERS.P4_RESEARCH) {
|
|
315
|
+
sectionHeader = '## Research';
|
|
316
|
+
} else if (entry.tier === PRIORITY_TIERS.P5_ARCHITECTURE) {
|
|
317
|
+
sectionHeader = '## Architecture';
|
|
318
|
+
} else if (entry.tier === PRIORITY_TIERS.P6_ADRS) {
|
|
319
|
+
sectionHeader = `## ${entry.label}`;
|
|
320
|
+
} else if (entry.tier === PRIORITY_TIERS.P7_ROADMAP) {
|
|
321
|
+
sectionHeader = '## Roadmap';
|
|
322
|
+
} else {
|
|
323
|
+
sectionHeader = `## ${entry.label}`;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!content) {
|
|
328
|
+
if (entry.required) {
|
|
329
|
+
sections.push(`${sectionHeader}\n[Not available]`);
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const sectionText = `${sectionHeader}\n${content}`;
|
|
335
|
+
const sectionTokens = estimateTokens(sectionText);
|
|
336
|
+
|
|
337
|
+
if (totalTokens + sectionTokens > budget) {
|
|
338
|
+
if (entry.required) {
|
|
339
|
+
// Truncate required content to fit
|
|
340
|
+
const remaining = budget - totalTokens;
|
|
341
|
+
const charLimit = remaining * 4; // CHARS_PER_TOKEN = 4
|
|
342
|
+
if (charLimit > 100) {
|
|
343
|
+
const truncatedContent = content.slice(0, charLimit - 20) + '\n[...truncated]';
|
|
344
|
+
sections.push(`${sectionHeader}\n${truncatedContent}`);
|
|
345
|
+
totalTokens += estimateTokens(`${sectionHeader}\n${truncatedContent}`);
|
|
346
|
+
truncated = true;
|
|
347
|
+
includedFiles.push({ ...entry, truncated: true });
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
truncated = true;
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
sections.push(sectionText);
|
|
356
|
+
totalTokens += sectionTokens;
|
|
357
|
+
includedFiles.push({ ...entry, truncated: false });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const assembledAt = new Date().toISOString();
|
|
361
|
+
|
|
362
|
+
// Build the wrapped context block
|
|
363
|
+
const metadata = [
|
|
364
|
+
'## Context Metadata',
|
|
365
|
+
`- Assembled: ${assembledAt}`,
|
|
366
|
+
`- Token estimate: ${totalTokens}`,
|
|
367
|
+
`- Truncated: ${truncated ? 'yes' : 'no'}`,
|
|
368
|
+
`- Agent type: ${agentType}`,
|
|
369
|
+
`- Budget: ${budget}`
|
|
370
|
+
].join('\n');
|
|
371
|
+
|
|
372
|
+
sections.push(metadata);
|
|
373
|
+
|
|
374
|
+
const context = [
|
|
375
|
+
'---BRAIN-CONTEXT-START---',
|
|
376
|
+
sections.join('\n\n'),
|
|
377
|
+
'---BRAIN-CONTEXT-END---'
|
|
378
|
+
].join('\n');
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
context,
|
|
382
|
+
manifest: includedFiles,
|
|
383
|
+
tokenEstimate: totalTokens,
|
|
384
|
+
truncated
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
assembleTaskContext,
|
|
390
|
+
collectPriorSummaries,
|
|
391
|
+
condenseSummary,
|
|
392
|
+
buildContextManifest,
|
|
393
|
+
calculateContextBudget,
|
|
394
|
+
detectStaleContext,
|
|
395
|
+
CONTEXT_BUDGETS,
|
|
396
|
+
PRIORITY_TIERS
|
|
397
|
+
};
|
package/bin/lib/cost.cjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { atomicWriteSync } = require('./state.cjs');
|
|
6
|
+
const { estimateTokens } = require('./tokens.cjs');
|
|
7
|
+
|
|
8
|
+
const METRICS_FILE = 'metrics.json';
|
|
9
|
+
|
|
10
|
+
// Default per-million-token pricing (conservative estimates)
|
|
11
|
+
const DEFAULT_PRICING = {
|
|
12
|
+
'claude-opus-4-6': { input: 15.00, output: 75.00 },
|
|
13
|
+
'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
|
|
14
|
+
'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
|
|
15
|
+
'claude-haiku-4-5-20251001': { input: 0.25, output: 1.25 },
|
|
16
|
+
'inherit': { input: 15.00, output: 75.00 } // Conservative default
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a default metrics object for a new brain project.
|
|
21
|
+
* @returns {object} Default metrics structure
|
|
22
|
+
*/
|
|
23
|
+
function createDefaultMetrics() {
|
|
24
|
+
return {
|
|
25
|
+
$schema: 'brain-metrics/v1',
|
|
26
|
+
budget: { ceiling_usd: null, warn_at_pct: 80, mode: 'warn' },
|
|
27
|
+
pricing: { ...DEFAULT_PRICING },
|
|
28
|
+
totals: { input_tokens: 0, output_tokens: 0, estimated_cost_usd: 0.00, agent_invocations: 0 },
|
|
29
|
+
by_agent: {},
|
|
30
|
+
by_phase: {},
|
|
31
|
+
ledger: []
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read metrics.json from the given directory.
|
|
37
|
+
* Returns default metrics if file does not exist or is corrupted.
|
|
38
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
39
|
+
* @returns {object} Metrics object
|
|
40
|
+
*/
|
|
41
|
+
function readMetrics(brainDir) {
|
|
42
|
+
try {
|
|
43
|
+
const metricsPath = path.join(brainDir, METRICS_FILE);
|
|
44
|
+
return JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return createDefaultMetrics();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write metrics.json atomically.
|
|
52
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
53
|
+
* @param {object} metrics - Metrics object to write
|
|
54
|
+
*/
|
|
55
|
+
function writeMetrics(brainDir, metrics) {
|
|
56
|
+
const metricsPath = path.join(brainDir, METRICS_FILE);
|
|
57
|
+
atomicWriteSync(metricsPath, JSON.stringify(metrics, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Estimate cost in USD for a given model invocation.
|
|
62
|
+
* @param {string} model - Model identifier
|
|
63
|
+
* @param {number} inputTokens - Number of input tokens
|
|
64
|
+
* @param {number} outputTokens - Number of output tokens
|
|
65
|
+
* @param {object} [pricing] - Optional pricing table override
|
|
66
|
+
* @returns {number} Estimated cost in USD (rounded to 4 decimal places)
|
|
67
|
+
*/
|
|
68
|
+
function estimateCost(model, inputTokens, outputTokens, pricing) {
|
|
69
|
+
const p = pricing || DEFAULT_PRICING;
|
|
70
|
+
const modelPricing = p[model] || p['inherit'] || { input: 15.00, output: 75.00 };
|
|
71
|
+
const inputCost = (inputTokens / 1_000_000) * modelPricing.input;
|
|
72
|
+
const outputCost = (outputTokens / 1_000_000) * modelPricing.output;
|
|
73
|
+
return Math.round((inputCost + outputCost) * 10000) / 10000;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Record an agent invocation in the metrics ledger.
|
|
78
|
+
* Updates totals, by_agent, and by_phase aggregates.
|
|
79
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
80
|
+
* @param {object} entry - Invocation data
|
|
81
|
+
* @param {string} entry.agent - Agent name (e.g. 'researcher', 'executor')
|
|
82
|
+
* @param {number} entry.phase - Phase number
|
|
83
|
+
* @param {string} [entry.plan] - Plan identifier
|
|
84
|
+
* @param {string} [entry.model] - Model used (defaults to 'inherit')
|
|
85
|
+
* @param {number} [entry.input_tokens] - Input token count
|
|
86
|
+
* @param {number} [entry.output_tokens] - Output token count
|
|
87
|
+
* @returns {object} Ledger entry with budget_status appended
|
|
88
|
+
*/
|
|
89
|
+
function recordInvocation(brainDir, entry) {
|
|
90
|
+
const metrics = readMetrics(brainDir);
|
|
91
|
+
|
|
92
|
+
const cost = estimateCost(
|
|
93
|
+
entry.model || 'inherit',
|
|
94
|
+
entry.input_tokens || 0,
|
|
95
|
+
entry.output_tokens || 0,
|
|
96
|
+
metrics.pricing
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const ledgerEntry = {
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
agent: entry.agent,
|
|
102
|
+
phase: entry.phase,
|
|
103
|
+
plan: entry.plan || null,
|
|
104
|
+
model: entry.model || 'inherit',
|
|
105
|
+
input_tokens: entry.input_tokens || 0,
|
|
106
|
+
output_tokens: entry.output_tokens || 0,
|
|
107
|
+
cost_usd: cost,
|
|
108
|
+
source: 'estimate'
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
metrics.ledger.push(ledgerEntry);
|
|
112
|
+
|
|
113
|
+
// Update totals
|
|
114
|
+
metrics.totals.input_tokens += ledgerEntry.input_tokens;
|
|
115
|
+
metrics.totals.output_tokens += ledgerEntry.output_tokens;
|
|
116
|
+
metrics.totals.estimated_cost_usd = Math.round((metrics.totals.estimated_cost_usd + cost) * 10000) / 10000;
|
|
117
|
+
metrics.totals.agent_invocations += 1;
|
|
118
|
+
|
|
119
|
+
// Update by_agent
|
|
120
|
+
const agentKey = entry.agent || 'unknown';
|
|
121
|
+
if (!metrics.by_agent[agentKey]) {
|
|
122
|
+
metrics.by_agent[agentKey] = { invocations: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
|
|
123
|
+
}
|
|
124
|
+
metrics.by_agent[agentKey].invocations += 1;
|
|
125
|
+
metrics.by_agent[agentKey].input_tokens += ledgerEntry.input_tokens;
|
|
126
|
+
metrics.by_agent[agentKey].output_tokens += ledgerEntry.output_tokens;
|
|
127
|
+
metrics.by_agent[agentKey].cost_usd = Math.round((metrics.by_agent[agentKey].cost_usd + cost) * 10000) / 10000;
|
|
128
|
+
|
|
129
|
+
// Update by_phase
|
|
130
|
+
const phaseKey = String(entry.phase || 0);
|
|
131
|
+
if (!metrics.by_phase[phaseKey]) {
|
|
132
|
+
metrics.by_phase[phaseKey] = { invocations: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
|
|
133
|
+
}
|
|
134
|
+
metrics.by_phase[phaseKey].invocations += 1;
|
|
135
|
+
metrics.by_phase[phaseKey].input_tokens += ledgerEntry.input_tokens;
|
|
136
|
+
metrics.by_phase[phaseKey].output_tokens += ledgerEntry.output_tokens;
|
|
137
|
+
metrics.by_phase[phaseKey].cost_usd = Math.round((metrics.by_phase[phaseKey].cost_usd + cost) * 10000) / 10000;
|
|
138
|
+
|
|
139
|
+
writeMetrics(brainDir, metrics);
|
|
140
|
+
|
|
141
|
+
return { ...ledgerEntry, budget_status: checkBudget(metrics) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check budget status against ceiling.
|
|
146
|
+
* @param {object} metrics - Metrics object
|
|
147
|
+
* @returns {object} Budget status with within_budget, spent, ceiling, pct_used, remaining, status
|
|
148
|
+
*/
|
|
149
|
+
function checkBudget(metrics) {
|
|
150
|
+
const ceiling = metrics.budget?.ceiling_usd;
|
|
151
|
+
const spent = metrics.totals?.estimated_cost_usd || 0;
|
|
152
|
+
|
|
153
|
+
if (ceiling === null || ceiling === undefined) {
|
|
154
|
+
return { within_budget: true, spent, ceiling: null, pct_used: 0, remaining: null, status: 'ok' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pct_used = ceiling > 0 ? Math.round((spent / ceiling) * 100) : 100;
|
|
158
|
+
const remaining = Math.round((ceiling - spent) * 10000) / 10000;
|
|
159
|
+
const warn_at = metrics.budget?.warn_at_pct || 80;
|
|
160
|
+
|
|
161
|
+
let status = 'ok';
|
|
162
|
+
if (pct_used >= 100) status = 'exceeded';
|
|
163
|
+
else if (pct_used >= warn_at) status = 'warning';
|
|
164
|
+
|
|
165
|
+
return { within_budget: pct_used < 100, spent, ceiling, pct_used, remaining, status };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check whether spending an estimated cost is allowed under the budget.
|
|
170
|
+
* Respects budget mode: 'warn' (allow with warning), 'hard-stop' (block), 'pause' (block).
|
|
171
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
172
|
+
* @param {number} estimatedCost - Estimated cost of the next operation
|
|
173
|
+
* @returns {object} { allowed, reason, budget_status }
|
|
174
|
+
*/
|
|
175
|
+
function canSpend(brainDir, estimatedCost) {
|
|
176
|
+
const metrics = readMetrics(brainDir);
|
|
177
|
+
const budgetStatus = checkBudget(metrics);
|
|
178
|
+
|
|
179
|
+
if (budgetStatus.ceiling === null) {
|
|
180
|
+
return { allowed: true, reason: null, budget_status: budgetStatus };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const wouldExceed = (budgetStatus.spent + estimatedCost) > budgetStatus.ceiling;
|
|
184
|
+
if (wouldExceed) {
|
|
185
|
+
const mode = metrics.budget?.mode || 'warn';
|
|
186
|
+
if (mode === 'hard-stop') {
|
|
187
|
+
return { allowed: false, reason: `Budget exceeded: $${budgetStatus.spent.toFixed(2)} + ~$${estimatedCost.toFixed(2)} > $${budgetStatus.ceiling.toFixed(2)} ceiling`, budget_status: budgetStatus };
|
|
188
|
+
}
|
|
189
|
+
if (mode === 'pause') {
|
|
190
|
+
return { allowed: false, reason: `Budget would be exceeded. Pausing. Spent: $${budgetStatus.spent.toFixed(2)}, Ceiling: $${budgetStatus.ceiling.toFixed(2)}`, budget_status: budgetStatus };
|
|
191
|
+
}
|
|
192
|
+
// 'warn' mode — allowed but with warning
|
|
193
|
+
return { allowed: true, reason: `WARNING: Budget will be exceeded ($${budgetStatus.spent.toFixed(2)} + ~$${estimatedCost.toFixed(2)} > $${budgetStatus.ceiling.toFixed(2)})`, budget_status: budgetStatus };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { allowed: true, reason: null, budget_status: budgetStatus };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Project remaining cost based on average spend per completed phase.
|
|
201
|
+
* @param {object} metrics - Metrics object
|
|
202
|
+
* @param {number} totalPhases - Total planned phases
|
|
203
|
+
* @param {number} currentPhase - Current phase number
|
|
204
|
+
* @returns {object} Projection with spent, projected_remaining, projected_total, avg_per_phase, confidence
|
|
205
|
+
*/
|
|
206
|
+
function projectCost(metrics, totalPhases, currentPhase) {
|
|
207
|
+
const completedPhases = Object.keys(metrics.by_phase).length;
|
|
208
|
+
const spent = metrics.totals?.estimated_cost_usd || 0;
|
|
209
|
+
|
|
210
|
+
if (completedPhases === 0) {
|
|
211
|
+
return { spent, projected_remaining: 0, projected_total: spent, avg_per_phase: 0, confidence: 'low' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const avg_per_phase = spent / completedPhases;
|
|
215
|
+
const remainingPhases = Math.max(0, totalPhases - currentPhase);
|
|
216
|
+
const projected_remaining = Math.round(avg_per_phase * remainingPhases * 100) / 100;
|
|
217
|
+
const projected_total = Math.round((spent + projected_remaining) * 100) / 100;
|
|
218
|
+
|
|
219
|
+
let confidence = 'low';
|
|
220
|
+
if (completedPhases >= 5) confidence = 'high';
|
|
221
|
+
else if (completedPhases >= 3) confidence = 'medium';
|
|
222
|
+
|
|
223
|
+
return { spent, projected_remaining, projected_total, avg_per_phase: Math.round(avg_per_phase * 100) / 100, confidence };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get cost breakdown by agent, sorted by cost descending.
|
|
228
|
+
* @param {object} metrics - Metrics object
|
|
229
|
+
* @returns {object[]} Array of { agent, cost, pct, invocations }
|
|
230
|
+
*/
|
|
231
|
+
function getAgentBreakdown(metrics) {
|
|
232
|
+
return Object.entries(metrics.by_agent || {})
|
|
233
|
+
.map(([agent, data]) => ({
|
|
234
|
+
agent,
|
|
235
|
+
cost: data.cost_usd,
|
|
236
|
+
pct: metrics.totals.estimated_cost_usd > 0 ? Math.round((data.cost_usd / metrics.totals.estimated_cost_usd) * 100) : 0,
|
|
237
|
+
invocations: data.invocations
|
|
238
|
+
}))
|
|
239
|
+
.sort((a, b) => b.cost - a.cost);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get cost breakdown by phase, sorted by phase number ascending.
|
|
244
|
+
* @param {object} metrics - Metrics object
|
|
245
|
+
* @returns {object[]} Array of { phase, cost, invocations, input_tokens, output_tokens }
|
|
246
|
+
*/
|
|
247
|
+
function getPhaseBreakdown(metrics) {
|
|
248
|
+
return Object.entries(metrics.by_phase || {})
|
|
249
|
+
.map(([phase, data]) => ({
|
|
250
|
+
phase: parseInt(phase, 10),
|
|
251
|
+
cost: data.cost_usd,
|
|
252
|
+
invocations: data.invocations,
|
|
253
|
+
input_tokens: data.input_tokens,
|
|
254
|
+
output_tokens: data.output_tokens
|
|
255
|
+
}))
|
|
256
|
+
.sort((a, b) => a.phase - b.phase);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
createDefaultMetrics,
|
|
261
|
+
readMetrics,
|
|
262
|
+
writeMetrics,
|
|
263
|
+
estimateCost,
|
|
264
|
+
estimateTokens, // re-export for convenience
|
|
265
|
+
recordInvocation,
|
|
266
|
+
checkBudget,
|
|
267
|
+
canSpend,
|
|
268
|
+
projectCost,
|
|
269
|
+
getAgentBreakdown,
|
|
270
|
+
getPhaseBreakdown,
|
|
271
|
+
DEFAULT_PRICING,
|
|
272
|
+
METRICS_FILE
|
|
273
|
+
};
|