@mcptoolshop/promo-kit 0.1.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,278 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Feedback Summary Generator
5
+ *
6
+ * Reads feedback.jsonl (append-only log), computes per-channel and per-slug
7
+ * statistics, generates recommendations, writes feedback-summary.json.
8
+ *
9
+ * Usage:
10
+ * node scripts/gen-feedback-summary.mjs [--dry-run]
11
+ *
12
+ * Reads:
13
+ * site/src/data/feedback.jsonl
14
+ *
15
+ * Writes:
16
+ * site/src/data/feedback-summary.json
17
+ */
18
+
19
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
20
+ import { resolve, join } from "node:path";
21
+ import { getConfig, getRoot } from "./lib/config.mjs";
22
+
23
+ const ROOT = getRoot();
24
+ const config = getConfig();
25
+
26
+ // ── Helpers ─────────────────────────────────────────────────
27
+
28
+ function safeParseJson(filePath, fallback = null) {
29
+ try {
30
+ return JSON.parse(readFileSync(filePath, "utf8"));
31
+ } catch {
32
+ return fallback;
33
+ }
34
+ }
35
+
36
+ const VALID_OUTCOMES = new Set(["sent", "opened", "replied", "ignored", "bounced"]);
37
+
38
+ function makeOutcomeCounter() {
39
+ return { sent: 0, opened: 0, replied: 0, ignored: 0, bounced: 0 };
40
+ }
41
+
42
+ // ── Core ────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Parse feedback lines from JSONL content.
46
+ * Skips empty lines and malformed JSON. Trims whitespace.
47
+ *
48
+ * @param {string} content - raw JSONL text
49
+ * @returns {Array<{ date: string, slug: string, channel: string, outcome: string, link?: string, notes?: string }>}
50
+ */
51
+ export function parseFeedbackLines(content) {
52
+ const lines = content.split("\n");
53
+ const entries = [];
54
+
55
+ for (const raw of lines) {
56
+ const line = raw.trim();
57
+ if (!line) continue;
58
+
59
+ let obj;
60
+ try {
61
+ obj = JSON.parse(line);
62
+ } catch {
63
+ console.warn(`Skipping malformed JSONL line: ${line.slice(0, 80)}`);
64
+ continue;
65
+ }
66
+
67
+ if (!obj.date || !obj.slug || !obj.channel || !obj.outcome) {
68
+ console.warn(`Skipping entry missing required fields: ${JSON.stringify(obj).slice(0, 80)}`);
69
+ continue;
70
+ }
71
+
72
+ if (!VALID_OUTCOMES.has(obj.outcome)) {
73
+ console.warn(`Skipping entry with invalid outcome "${obj.outcome}": ${JSON.stringify(obj).slice(0, 80)}`);
74
+ continue;
75
+ }
76
+
77
+ entries.push(obj);
78
+ }
79
+
80
+ return entries;
81
+ }
82
+
83
+ /**
84
+ * Compute summary statistics from parsed feedback entries.
85
+ *
86
+ * @param {Array<object>} entries
87
+ * @returns {{
88
+ * totalEntries: number,
89
+ * perChannel: Record<string, { sent: number, opened: number, replied: number, ignored: number, bounced: number }>,
90
+ * perSlug: Record<string, { sent: number, opened: number, replied: number, ignored: number, bounced: number }>,
91
+ * recommendations: string[],
92
+ * bestPerformingChannel: string|null,
93
+ * replyRate: number
94
+ * }}
95
+ */
96
+ export function computeFeedbackSummary(entries) {
97
+ const perChannel = {};
98
+ const perSlug = {};
99
+ const perExperiment = {};
100
+ let totalReplied = 0;
101
+
102
+ for (const entry of entries) {
103
+ const { channel, slug, outcome } = entry;
104
+
105
+ if (!perChannel[channel]) perChannel[channel] = makeOutcomeCounter();
106
+ perChannel[channel][outcome]++;
107
+
108
+ if (!perSlug[slug]) perSlug[slug] = makeOutcomeCounter();
109
+ perSlug[slug][outcome]++;
110
+
111
+ if (outcome === "replied") totalReplied++;
112
+
113
+ // Experiment variant tracking
114
+ if (entry.experimentId && entry.variantKey) {
115
+ if (!perExperiment[entry.experimentId]) perExperiment[entry.experimentId] = {};
116
+ if (!perExperiment[entry.experimentId][entry.variantKey]) {
117
+ perExperiment[entry.experimentId][entry.variantKey] = makeOutcomeCounter();
118
+ }
119
+ perExperiment[entry.experimentId][entry.variantKey][outcome]++;
120
+ }
121
+ }
122
+
123
+ const totalEntries = entries.length;
124
+ const replyRate = totalEntries > 0 ? totalReplied / totalEntries : 0;
125
+
126
+ // Find best performing channel by reply/sent ratio
127
+ let bestPerformingChannel = null;
128
+ let bestRatio = -1;
129
+
130
+ for (const [channel, counts] of Object.entries(perChannel)) {
131
+ if (counts.sent > 0) {
132
+ const ratio = counts.replied / counts.sent;
133
+ if (ratio > bestRatio) {
134
+ bestRatio = ratio;
135
+ bestPerformingChannel = channel;
136
+ }
137
+ }
138
+ }
139
+
140
+ // Generate recommendations
141
+ const recommendations = [];
142
+
143
+ for (const [channel, counts] of Object.entries(perChannel)) {
144
+ const total = counts.sent + counts.opened + counts.replied + counts.ignored + counts.bounced;
145
+ if (total > 0) {
146
+ if (counts.replied / total > 0.5) {
147
+ recommendations.push(`${channel} performs well -- prioritize for future runs`);
148
+ }
149
+ if (counts.ignored / total > 0.7) {
150
+ recommendations.push(`${channel} underperforming -- review approach or drop`);
151
+ }
152
+ }
153
+ }
154
+
155
+ for (const [slug, counts] of Object.entries(perSlug)) {
156
+ const total = counts.sent + counts.opened + counts.replied + counts.ignored + counts.bounced;
157
+ if (total > 0 && counts.replied === 0) {
158
+ recommendations.push(`${slug} has no engagement -- review messaging`);
159
+ }
160
+ }
161
+
162
+ // Experiment-specific recommendations
163
+ for (const [expId, arms] of Object.entries(perExperiment)) {
164
+ const controlCounts = arms["control"];
165
+ const variantKeys = Object.keys(arms).filter((k) => k !== "control");
166
+
167
+ for (const vk of variantKeys) {
168
+ const variantCounts = arms[vk];
169
+ const controlTotal = controlCounts
170
+ ? controlCounts.sent + controlCounts.opened + controlCounts.replied + controlCounts.ignored + controlCounts.bounced
171
+ : 0;
172
+ const variantTotal = variantCounts.sent + variantCounts.opened + variantCounts.replied + variantCounts.ignored + variantCounts.bounced;
173
+
174
+ // Insufficient data check
175
+ if (controlTotal < 5 || variantTotal < 5) {
176
+ recommendations.push(`${expId}: insufficient data (${controlTotal} control, ${variantTotal} variant entries)`);
177
+ continue;
178
+ }
179
+
180
+ const controlReplyRate = controlCounts.replied / controlTotal;
181
+ const variantReplyRate = variantCounts.replied / variantTotal;
182
+
183
+ if (controlReplyRate > 0 && variantReplyRate / controlReplyRate > 2) {
184
+ const ratio = Math.round(variantReplyRate / controlReplyRate * 10) / 10;
185
+ recommendations.push(`${expId}: ${vk} outperforms control (${ratio}x reply rate)`);
186
+ } else if (variantReplyRate > 0 && controlReplyRate / variantReplyRate > 2) {
187
+ recommendations.push(`${expId}: control outperforms ${vk} -- consider concluding`);
188
+ }
189
+ }
190
+ }
191
+
192
+ return {
193
+ totalEntries,
194
+ perChannel,
195
+ perSlug,
196
+ recommendations,
197
+ bestPerformingChannel,
198
+ replyRate,
199
+ perExperiment,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Full pipeline: read feedback.jsonl, compute summary, write feedback-summary.json.
205
+ *
206
+ * @param {{ dataDir?: string, dryRun?: boolean }} opts
207
+ * @returns {object} Summary object
208
+ */
209
+ export function generateFeedbackSummary(opts = {}) {
210
+ const dataDir = opts.dataDir || join(ROOT, config.paths.dataDir);
211
+ const dryRun = opts.dryRun || false;
212
+
213
+ const feedbackPath = join(dataDir, "feedback.jsonl");
214
+ const summaryPath = join(dataDir, "feedback-summary.json");
215
+
216
+ // Zero-state if file missing or empty
217
+ if (!existsSync(feedbackPath)) {
218
+ const zeroState = {
219
+ generatedAt: new Date().toISOString(),
220
+ totalEntries: 0,
221
+ perChannel: {},
222
+ perSlug: {},
223
+ recommendations: [],
224
+ bestPerformingChannel: null,
225
+ replyRate: 0,
226
+ perExperiment: {},
227
+ };
228
+ if (!dryRun) {
229
+ writeFileSync(summaryPath, JSON.stringify(zeroState, null, 2) + "\n", "utf8");
230
+ }
231
+ return zeroState;
232
+ }
233
+
234
+ const content = readFileSync(feedbackPath, "utf8");
235
+ if (!content.trim()) {
236
+ const zeroState = {
237
+ generatedAt: new Date().toISOString(),
238
+ totalEntries: 0,
239
+ perChannel: {},
240
+ perSlug: {},
241
+ recommendations: [],
242
+ bestPerformingChannel: null,
243
+ replyRate: 0,
244
+ perExperiment: {},
245
+ };
246
+ if (!dryRun) {
247
+ writeFileSync(summaryPath, JSON.stringify(zeroState, null, 2) + "\n", "utf8");
248
+ }
249
+ return zeroState;
250
+ }
251
+
252
+ const entries = parseFeedbackLines(content);
253
+ const summary = computeFeedbackSummary(entries);
254
+
255
+ const output = {
256
+ generatedAt: new Date().toISOString(),
257
+ ...summary,
258
+ };
259
+
260
+ if (!dryRun) {
261
+ writeFileSync(summaryPath, JSON.stringify(output, null, 2) + "\n", "utf8");
262
+ }
263
+
264
+ return output;
265
+ }
266
+
267
+ // ── Main ────────────────────────────────────────────────────
268
+
269
+ const isMain = process.argv[1] && resolve(process.argv[1]).endsWith("gen-feedback-summary.mjs");
270
+
271
+ if (isMain) {
272
+ const dryRun = process.argv.includes("--dry-run");
273
+ console.log("Generating feedback summary...");
274
+ if (dryRun) console.log(" Mode: DRY RUN");
275
+ const summary = generateFeedbackSummary({ dryRun });
276
+ console.log(` Entries: ${summary.totalEntries}`);
277
+ console.log(` Recommendations: ${summary.recommendations.length}`);
278
+ }