@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/lib/templates.js CHANGED
@@ -1,4 +1,4 @@
1
- 'use strict';
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('node:fs');
12
- const path = require('node:path');
13
- const http = require('node:http');
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, '..', '..', 'barn', 'templates'),
18
- path.join(__dirname, '..', '..', '..', 'barn', 'templates'),
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('.html'));
31
- const templates = files.map(f => {
32
- const content = fs.readFileSync(path.join(dir, f), 'utf8');
33
- const placeholders = [...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || [])];
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('.html', ''),
38
+ name: f.replace(".html", ""),
37
39
  placeholders,
38
- description: commentMatch ? commentMatch[1] : '',
39
- source: 'filesystem',
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(`http://127.0.0.1:${BARN_PORT}/api/state`, { timeout: 2000 }, (res) => {
58
- let body = '';
59
- res.on('data', (chunk) => { body += chunk; });
60
- res.on('end', () => {
61
- try {
62
- const state = JSON.parse(body);
63
- const templates = (state.templates || []).map(t => ({
64
- name: t.name,
65
- placeholders: t.placeholders || [],
66
- description: t.description || '',
67
- source: 'http',
68
- }));
69
- resolve({ available: true, templates, source: `http://127.0.0.1:${BARN_PORT}` });
70
- } catch {
71
- resolve(discoverTemplates());
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 };