@grainulation/harvest 1.0.0 → 1.0.2
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/CODE_OF_CONDUCT.md +25 -0
- package/CONTRIBUTING.md +93 -0
- package/README.md +44 -45
- package/bin/harvest.js +135 -60
- package/lib/analyzer.js +33 -26
- package/lib/calibration.js +199 -32
- package/lib/dashboard.js +54 -32
- package/lib/decay.js +224 -18
- package/lib/farmer.js +54 -38
- package/lib/harvest-card.js +475 -0
- package/lib/patterns.js +64 -43
- package/lib/report.js +243 -61
- package/lib/server.js +323 -150
- package/lib/templates.js +47 -32
- package/lib/token-tracker.js +288 -0
- package/lib/tokens.js +317 -0
- package/lib/velocity.js +68 -40
- package/lib/wrapped.js +489 -0
- package/package.json +10 -3
package/lib/templates.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* harvest -> barn edge: template discovery for report formatting.
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* harvest's built-in formatting when barn is not reachable.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
const fs = require(
|
|
12
|
-
const path = require(
|
|
13
|
-
const http = require(
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
const http = require("node:http");
|
|
14
14
|
|
|
15
15
|
const BARN_PORT = 9093;
|
|
16
16
|
const BARN_SIBLINGS = [
|
|
17
|
-
path.join(__dirname,
|
|
18
|
-
path.join(__dirname,
|
|
17
|
+
path.join(__dirname, "..", "..", "barn", "templates"),
|
|
18
|
+
path.join(__dirname, "..", "..", "..", "barn", "templates"),
|
|
19
19
|
];
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -27,16 +27,18 @@ function discoverTemplates() {
|
|
|
27
27
|
for (const dir of BARN_SIBLINGS) {
|
|
28
28
|
if (fs.existsSync(dir)) {
|
|
29
29
|
try {
|
|
30
|
-
const files = fs.readdirSync(dir).filter(f => f.endsWith(
|
|
31
|
-
const templates = files.map(f => {
|
|
32
|
-
const content = fs.readFileSync(path.join(dir, f),
|
|
33
|
-
const placeholders = [
|
|
30
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".html"));
|
|
31
|
+
const templates = files.map((f) => {
|
|
32
|
+
const content = fs.readFileSync(path.join(dir, f), "utf8");
|
|
33
|
+
const placeholders = [
|
|
34
|
+
...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || []),
|
|
35
|
+
];
|
|
34
36
|
const commentMatch = content.match(/<!--\s*(.*?)\s*-->/);
|
|
35
37
|
return {
|
|
36
|
-
name: f.replace(
|
|
38
|
+
name: f.replace(".html", ""),
|
|
37
39
|
placeholders,
|
|
38
|
-
description: commentMatch ? commentMatch[1] :
|
|
39
|
-
source:
|
|
40
|
+
description: commentMatch ? commentMatch[1] : "",
|
|
41
|
+
source: "filesystem",
|
|
40
42
|
};
|
|
41
43
|
});
|
|
42
44
|
return { available: true, templates, source: dir };
|
|
@@ -54,26 +56,39 @@ function discoverTemplates() {
|
|
|
54
56
|
*/
|
|
55
57
|
function discoverTemplatesAsync() {
|
|
56
58
|
return new Promise((resolve) => {
|
|
57
|
-
const req = http.get(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
res
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
const req = http.get(
|
|
60
|
+
`http://127.0.0.1:${BARN_PORT}/api/state`,
|
|
61
|
+
{ timeout: 2000 },
|
|
62
|
+
(res) => {
|
|
63
|
+
let body = "";
|
|
64
|
+
res.on("data", (chunk) => {
|
|
65
|
+
body += chunk;
|
|
66
|
+
});
|
|
67
|
+
res.on("end", () => {
|
|
68
|
+
try {
|
|
69
|
+
const state = JSON.parse(body);
|
|
70
|
+
const templates = (state.templates || []).map((t) => ({
|
|
71
|
+
name: t.name,
|
|
72
|
+
placeholders: t.placeholders || [],
|
|
73
|
+
description: t.description || "",
|
|
74
|
+
source: "http",
|
|
75
|
+
}));
|
|
76
|
+
resolve({
|
|
77
|
+
available: true,
|
|
78
|
+
templates,
|
|
79
|
+
source: `http://127.0.0.1:${BARN_PORT}`,
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
resolve(discoverTemplates());
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
req.on("error", () => resolve(discoverTemplates()));
|
|
88
|
+
req.on("timeout", () => {
|
|
89
|
+
req.destroy();
|
|
90
|
+
resolve(discoverTemplates());
|
|
74
91
|
});
|
|
75
|
-
req.on('error', () => resolve(discoverTemplates()));
|
|
76
|
-
req.on('timeout', () => { req.destroy(); resolve(discoverTemplates()); });
|
|
77
92
|
});
|
|
78
93
|
}
|
|
79
94
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { computeCost, DEFAULT_PRICING } = require("./tokens.js");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Token Tracker — reads sprint_outcomes.jsonl and calculates cost-per-verified-claim.
|
|
9
|
+
*
|
|
10
|
+
* sprint_outcomes.jsonl is an append-only log where each line is a JSON object:
|
|
11
|
+
* { sprint, timestamp, input_tokens, output_tokens, cache_read_input_tokens,
|
|
12
|
+
* cache_creation_input_tokens, model, total_cost_usd, claims_count,
|
|
13
|
+
* verified_claims_count, phase }
|
|
14
|
+
*
|
|
15
|
+
* This module aggregates that data into actionable cost metrics.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const OUTCOMES_FILE = "sprint_outcomes.jsonl";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load sprint outcomes from a JSONL file.
|
|
22
|
+
* @param {string} dir - Directory containing sprint_outcomes.jsonl
|
|
23
|
+
* @returns {Array<object>} Parsed outcome entries
|
|
24
|
+
*/
|
|
25
|
+
function loadOutcomes(dir) {
|
|
26
|
+
const filePath = path.join(dir, OUTCOMES_FILE);
|
|
27
|
+
if (!fs.existsSync(filePath)) return [];
|
|
28
|
+
|
|
29
|
+
const lines = fs
|
|
30
|
+
.readFileSync(filePath, "utf8")
|
|
31
|
+
.split("\n")
|
|
32
|
+
.filter((line) => line.trim());
|
|
33
|
+
|
|
34
|
+
const outcomes = [];
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
try {
|
|
37
|
+
outcomes.push(JSON.parse(line));
|
|
38
|
+
} catch {
|
|
39
|
+
// skip malformed lines
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return outcomes;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Append an outcome entry to the JSONL log.
|
|
47
|
+
* @param {string} dir - Directory containing sprint_outcomes.jsonl
|
|
48
|
+
* @param {object} entry - Outcome data to append
|
|
49
|
+
*/
|
|
50
|
+
function appendOutcome(dir, entry) {
|
|
51
|
+
const filePath = path.join(dir, OUTCOMES_FILE);
|
|
52
|
+
const line = JSON.stringify({
|
|
53
|
+
...entry,
|
|
54
|
+
timestamp: entry.timestamp || new Date().toISOString(),
|
|
55
|
+
});
|
|
56
|
+
fs.appendFileSync(filePath, line + "\n", "utf8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Calculate cost-per-verified-claim from sprint_outcomes.jsonl.
|
|
61
|
+
*
|
|
62
|
+
* Returns per-sprint and aggregate metrics:
|
|
63
|
+
* - costPerVerifiedClaim: total cost / total verified claims
|
|
64
|
+
* - costPerClaim: total cost / total claims
|
|
65
|
+
* - costTrend: are sprints getting cheaper per verified claim over time?
|
|
66
|
+
* - modelBreakdown: cost by model
|
|
67
|
+
* - phaseBreakdown: cost by sprint phase
|
|
68
|
+
*
|
|
69
|
+
* @param {string} dir - Directory containing sprint_outcomes.jsonl
|
|
70
|
+
* @returns {object} Token tracking report
|
|
71
|
+
*/
|
|
72
|
+
function trackCosts(dir) {
|
|
73
|
+
const outcomes = loadOutcomes(dir);
|
|
74
|
+
|
|
75
|
+
if (outcomes.length === 0) {
|
|
76
|
+
return {
|
|
77
|
+
summary: {
|
|
78
|
+
totalEntries: 0,
|
|
79
|
+
totalCostUsd: 0,
|
|
80
|
+
totalClaims: 0,
|
|
81
|
+
totalVerifiedClaims: 0,
|
|
82
|
+
costPerClaim: null,
|
|
83
|
+
costPerVerifiedClaim: null,
|
|
84
|
+
avgCostPerSprint: null,
|
|
85
|
+
},
|
|
86
|
+
perSprint: [],
|
|
87
|
+
modelBreakdown: {},
|
|
88
|
+
phaseBreakdown: {},
|
|
89
|
+
costTrend: null,
|
|
90
|
+
insight:
|
|
91
|
+
"No sprint outcome data found. Append entries to sprint_outcomes.jsonl to track costs.",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Group by sprint name
|
|
96
|
+
const bySprint = new Map();
|
|
97
|
+
for (const o of outcomes) {
|
|
98
|
+
const key = o.sprint || "unknown";
|
|
99
|
+
if (!bySprint.has(key)) bySprint.set(key, []);
|
|
100
|
+
bySprint.get(key).push(o);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let totalCostUsd = 0;
|
|
104
|
+
let totalClaims = 0;
|
|
105
|
+
let totalVerifiedClaims = 0;
|
|
106
|
+
|
|
107
|
+
const perSprint = [];
|
|
108
|
+
const modelBreakdown = {};
|
|
109
|
+
const phaseBreakdown = {};
|
|
110
|
+
|
|
111
|
+
for (const [sprintName, entries] of bySprint) {
|
|
112
|
+
let sprintCost = 0;
|
|
113
|
+
let sprintClaims = 0;
|
|
114
|
+
let sprintVerified = 0;
|
|
115
|
+
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
const cost =
|
|
118
|
+
e.total_cost_usd != null
|
|
119
|
+
? e.total_cost_usd
|
|
120
|
+
: computeCost(
|
|
121
|
+
{
|
|
122
|
+
input_tokens: e.input_tokens || 0,
|
|
123
|
+
output_tokens: e.output_tokens || 0,
|
|
124
|
+
cache_read_input_tokens: e.cache_read_input_tokens || 0,
|
|
125
|
+
cache_creation_input_tokens: e.cache_creation_input_tokens || 0,
|
|
126
|
+
},
|
|
127
|
+
e.model,
|
|
128
|
+
).totalCostUsd;
|
|
129
|
+
|
|
130
|
+
sprintCost += cost;
|
|
131
|
+
sprintClaims += e.claims_count || 0;
|
|
132
|
+
sprintVerified += e.verified_claims_count || 0;
|
|
133
|
+
|
|
134
|
+
// Model breakdown
|
|
135
|
+
const model = e.model || "unknown";
|
|
136
|
+
modelBreakdown[model] = (modelBreakdown[model] || 0) + cost;
|
|
137
|
+
|
|
138
|
+
// Phase breakdown
|
|
139
|
+
const phase = e.phase || "unknown";
|
|
140
|
+
phaseBreakdown[phase] = (phaseBreakdown[phase] || 0) + cost;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
totalCostUsd += sprintCost;
|
|
144
|
+
totalClaims += sprintClaims;
|
|
145
|
+
totalVerifiedClaims += sprintVerified;
|
|
146
|
+
|
|
147
|
+
perSprint.push({
|
|
148
|
+
sprint: sprintName,
|
|
149
|
+
cost: round6(sprintCost),
|
|
150
|
+
claims: sprintClaims,
|
|
151
|
+
verifiedClaims: sprintVerified,
|
|
152
|
+
costPerClaim: sprintClaims > 0 ? round6(sprintCost / sprintClaims) : null,
|
|
153
|
+
costPerVerifiedClaim:
|
|
154
|
+
sprintVerified > 0 ? round6(sprintCost / sprintVerified) : null,
|
|
155
|
+
entries: entries.length,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sort by timestamp of first entry
|
|
160
|
+
perSprint.sort((a, b) => {
|
|
161
|
+
const aTime = bySprint.get(a.sprint)?.[0]?.timestamp || "";
|
|
162
|
+
const bTime = bySprint.get(b.sprint)?.[0]?.timestamp || "";
|
|
163
|
+
return aTime.localeCompare(bTime);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Cost trend: compare first half vs second half of sprints
|
|
167
|
+
let costTrend = null;
|
|
168
|
+
if (perSprint.length >= 4) {
|
|
169
|
+
const mid = Math.floor(perSprint.length / 2);
|
|
170
|
+
const firstHalf = perSprint
|
|
171
|
+
.slice(0, mid)
|
|
172
|
+
.filter((s) => s.costPerVerifiedClaim != null);
|
|
173
|
+
const secondHalf = perSprint
|
|
174
|
+
.slice(mid)
|
|
175
|
+
.filter((s) => s.costPerVerifiedClaim != null);
|
|
176
|
+
|
|
177
|
+
if (firstHalf.length > 0 && secondHalf.length > 0) {
|
|
178
|
+
const avgFirst = avg(firstHalf.map((s) => s.costPerVerifiedClaim));
|
|
179
|
+
const avgSecond = avg(secondHalf.map((s) => s.costPerVerifiedClaim));
|
|
180
|
+
const changePercent =
|
|
181
|
+
avgFirst > 0
|
|
182
|
+
? Math.round(((avgSecond - avgFirst) / avgFirst) * 100)
|
|
183
|
+
: null;
|
|
184
|
+
|
|
185
|
+
costTrend = {
|
|
186
|
+
direction:
|
|
187
|
+
avgSecond < avgFirst
|
|
188
|
+
? "improving"
|
|
189
|
+
: avgSecond > avgFirst
|
|
190
|
+
? "worsening"
|
|
191
|
+
: "stable",
|
|
192
|
+
firstHalfAvg: round6(avgFirst),
|
|
193
|
+
secondHalfAvg: round6(avgSecond),
|
|
194
|
+
changePercent,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Round model and phase breakdowns
|
|
200
|
+
for (const k of Object.keys(modelBreakdown))
|
|
201
|
+
modelBreakdown[k] = round6(modelBreakdown[k]);
|
|
202
|
+
for (const k of Object.keys(phaseBreakdown))
|
|
203
|
+
phaseBreakdown[k] = round6(phaseBreakdown[k]);
|
|
204
|
+
|
|
205
|
+
const costPerClaim =
|
|
206
|
+
totalClaims > 0 ? round6(totalCostUsd / totalClaims) : null;
|
|
207
|
+
const costPerVerifiedClaim =
|
|
208
|
+
totalVerifiedClaims > 0 ? round6(totalCostUsd / totalVerifiedClaims) : null;
|
|
209
|
+
const sprintCount = perSprint.length;
|
|
210
|
+
const avgCostPerSprint =
|
|
211
|
+
sprintCount > 0 ? round6(totalCostUsd / sprintCount) : null;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
summary: {
|
|
215
|
+
totalEntries: outcomes.length,
|
|
216
|
+
totalSprints: sprintCount,
|
|
217
|
+
totalCostUsd: round6(totalCostUsd),
|
|
218
|
+
totalClaims,
|
|
219
|
+
totalVerifiedClaims,
|
|
220
|
+
costPerClaim,
|
|
221
|
+
costPerVerifiedClaim,
|
|
222
|
+
avgCostPerSprint,
|
|
223
|
+
},
|
|
224
|
+
perSprint,
|
|
225
|
+
modelBreakdown,
|
|
226
|
+
phaseBreakdown,
|
|
227
|
+
costTrend,
|
|
228
|
+
insight: generateTrackerInsight(
|
|
229
|
+
costPerVerifiedClaim,
|
|
230
|
+
costTrend,
|
|
231
|
+
modelBreakdown,
|
|
232
|
+
),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function generateTrackerInsight(costPerVerified, costTrend, modelBreakdown) {
|
|
237
|
+
const parts = [];
|
|
238
|
+
|
|
239
|
+
if (costPerVerified !== null) {
|
|
240
|
+
if (costPerVerified < 0.01) {
|
|
241
|
+
parts.push(
|
|
242
|
+
`Excellent efficiency: $${costPerVerified.toFixed(4)} per verified claim.`,
|
|
243
|
+
);
|
|
244
|
+
} else if (costPerVerified < 0.05) {
|
|
245
|
+
parts.push(
|
|
246
|
+
`Good efficiency: $${costPerVerified.toFixed(4)} per verified claim.`,
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
parts.push(
|
|
250
|
+
`Cost per verified claim: $${costPerVerified.toFixed(4)}. Consider caching or reducing exploratory queries.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (costTrend) {
|
|
256
|
+
if (costTrend.direction === "improving") {
|
|
257
|
+
parts.push(
|
|
258
|
+
`Research is getting cheaper: cost per verified claim improved ${Math.abs(costTrend.changePercent)}% across recent sprints.`,
|
|
259
|
+
);
|
|
260
|
+
} else if (costTrend.direction === "worsening") {
|
|
261
|
+
parts.push(
|
|
262
|
+
`Cost per verified claim increased ${costTrend.changePercent}% in recent sprints -- investigate token usage patterns.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const models = Object.keys(modelBreakdown);
|
|
268
|
+
if (models.length > 1) {
|
|
269
|
+
const sorted = models.sort((a, b) => modelBreakdown[b] - modelBreakdown[a]);
|
|
270
|
+
parts.push(
|
|
271
|
+
`Top model by spend: ${sorted[0]} ($${modelBreakdown[sorted[0]].toFixed(4)}).`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return parts.length > 0
|
|
276
|
+
? parts.join(" ")
|
|
277
|
+
: "Token cost data tracked from sprint_outcomes.jsonl.";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function avg(arr) {
|
|
281
|
+
return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function round6(n) {
|
|
285
|
+
return Math.round(n * 1_000_000) / 1_000_000;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { loadOutcomes, appendOutcome, trackCosts, OUTCOMES_FILE };
|