thumbgate 1.16.21 → 1.17.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +11 -5
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +15 -1
- package/config/github-about.json +1 -1
- package/config/pro/constraints-pro.json +57 -0
- package/config/pro/prevention-rules-pro.md +27 -0
- package/config/pro/reminders-pro.json +38 -0
- package/config/pro/thompson-presets.json +38 -0
- package/package.json +5 -2
- package/public/blog.html +18 -19
- package/public/compare.html +2 -2
- package/public/guide.html +1 -1
- package/public/index.html +164 -418
- package/public/numbers.html +42 -29
- package/scripts/billing.js +62 -3
- package/scripts/gate-stats.js +29 -8
- package/scripts/rate-limiter.js +15 -15
- package/src/api/server.js +69 -18
package/public/numbers.html
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<meta name="generator" content="ThumbGate">
|
|
7
7
|
<meta name="author" content="Igor Ganapolsky">
|
|
8
8
|
<title>ThumbGate — The Numbers | First-Party Data Snapshot</title>
|
|
9
|
-
<meta name="description" content="ThumbGate's generated first-party operational snapshot:
|
|
9
|
+
<meta name="description" content="ThumbGate's generated first-party operational snapshot: configured pre-action checks, recorded block/warn events, estimated LLM token savings when events exist, and scorer calibration when the sample supports it.">
|
|
10
10
|
<meta property="og:title" content="ThumbGate — The Numbers">
|
|
11
|
-
<meta property="og:description" content="Generated first-party operational
|
|
11
|
+
<meta property="og:description" content="Generated first-party operational snapshot: configured gates, recorded interventions, and explicit zero-evidence caveats.">
|
|
12
12
|
<meta property="og:type" content="website">
|
|
13
13
|
<meta property="og:url" content="https://thumbgate-production.up.railway.app/numbers">
|
|
14
14
|
<meta name="twitter:card" content="summary_large_image">
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"alternateName": "thumbgate",
|
|
26
26
|
"applicationCategory": "DeveloperApplication",
|
|
27
27
|
"operatingSystem": "Cross-platform, Node.js >=18.18.0",
|
|
28
|
-
"softwareVersion": "1.
|
|
28
|
+
"softwareVersion": "1.17.0",
|
|
29
29
|
"url": "https://thumbgate-production.up.railway.app/numbers",
|
|
30
|
-
"dateModified": "2026-05-
|
|
30
|
+
"dateModified": "2026-05-07",
|
|
31
31
|
"creator": {
|
|
32
32
|
"@type": "Person",
|
|
33
33
|
"name": "Igor Ganapolsky",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
{
|
|
45
45
|
"@context": "https://schema.org",
|
|
46
46
|
"@type": "Dataset",
|
|
47
|
-
"name": "ThumbGate
|
|
48
|
-
"description": "First-party operational
|
|
47
|
+
"name": "ThumbGate Operational Snapshot",
|
|
48
|
+
"description": "First-party operational snapshot from the ThumbGate pre-action check runtime: configured checks, recorded block/warn events, estimated token savings from recorded blocks, and Bayes error rate when the sample supports it.",
|
|
49
49
|
"url": "https://thumbgate-production.up.railway.app/numbers",
|
|
50
50
|
"license": "https://opensource.org/licenses/MIT",
|
|
51
51
|
"creator": {
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"https://www.linkedin.com/in/igorganapolsky"
|
|
58
58
|
]
|
|
59
59
|
},
|
|
60
|
-
"dateModified": "2026-05-
|
|
61
|
-
"datePublished": "2026-05-
|
|
60
|
+
"dateModified": "2026-05-07",
|
|
61
|
+
"datePublished": "2026-05-07",
|
|
62
62
|
"keywords": [
|
|
63
63
|
"AI agent gates",
|
|
64
64
|
"LLM token savings",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"variableMeasured": [
|
|
70
70
|
{
|
|
71
71
|
"@type": "PropertyValue",
|
|
72
|
-
"name": "
|
|
72
|
+
"name": "configured_gates",
|
|
73
73
|
"value": 36
|
|
74
74
|
},
|
|
75
75
|
{
|
|
@@ -119,6 +119,8 @@
|
|
|
119
119
|
--cyan: #22d3ee;
|
|
120
120
|
--green: #34d399;
|
|
121
121
|
--amber: #fbbf24;
|
|
122
|
+
--amber-soft: rgba(251, 191, 36, 0.1);
|
|
123
|
+
--amber-line: rgba(251, 191, 36, 0.34);
|
|
122
124
|
}
|
|
123
125
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
|
|
124
126
|
nav { padding: 1rem 2rem; border-bottom: 1px solid var(--border); display: flex; gap: 1.5rem; align-items: center; }
|
|
@@ -140,6 +142,16 @@
|
|
|
140
142
|
font-weight: 600;
|
|
141
143
|
margin-bottom: 2.5rem;
|
|
142
144
|
}
|
|
145
|
+
.truth-note {
|
|
146
|
+
background: var(--amber-soft);
|
|
147
|
+
border: 1px solid var(--amber-line);
|
|
148
|
+
border-radius: 12px;
|
|
149
|
+
color: #fde68a;
|
|
150
|
+
padding: 16px 18px;
|
|
151
|
+
margin: 0 0 2.5rem;
|
|
152
|
+
font-size: 0.95rem;
|
|
153
|
+
}
|
|
154
|
+
.truth-note strong { color: #fff3bd; }
|
|
143
155
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; margin: 1.5rem 0; }
|
|
144
156
|
.stat-card {
|
|
145
157
|
background: var(--bg-card);
|
|
@@ -189,33 +201,34 @@
|
|
|
189
201
|
|
|
190
202
|
<main class="container">
|
|
191
203
|
<h1>The Numbers</h1>
|
|
192
|
-
<p class="subtitle">Generated first-party operational
|
|
193
|
-
<div class="freshness">Updated: 2026-05-
|
|
204
|
+
<p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
|
|
205
|
+
<div class="freshness">Updated: 2026-05-07 · Version 1.17.0</div>
|
|
206
|
+
<div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
|
|
194
207
|
|
|
195
208
|
<h2>Gate enforcement</h2>
|
|
196
209
|
<div class="stats-grid">
|
|
197
210
|
<div class="stat-card">
|
|
198
|
-
<div class="stat-label">
|
|
211
|
+
<div class="stat-label">Configured checks</div>
|
|
199
212
|
<div class="stat-value">36</div>
|
|
200
|
-
<div class="stat-sub">36
|
|
213
|
+
<div class="stat-sub">36 shipped defaults · 0 auto-promoted; inventory, not usage</div>
|
|
201
214
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
|
|
202
215
|
</div>
|
|
203
216
|
<div class="stat-card">
|
|
204
217
|
<div class="stat-label">Actions blocked</div>
|
|
205
218
|
<div class="stat-value">0</div>
|
|
206
|
-
<div class="stat-sub">
|
|
219
|
+
<div class="stat-sub">no recorded hard-block events in this snapshot</div>
|
|
207
220
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
|
|
208
221
|
</div>
|
|
209
222
|
<div class="stat-card">
|
|
210
223
|
<div class="stat-label">Actions warned</div>
|
|
211
224
|
<div class="stat-value">0</div>
|
|
212
|
-
<div class="stat-sub">soft
|
|
225
|
+
<div class="stat-sub">no recorded soft-warning events in this snapshot</div>
|
|
213
226
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
|
|
214
227
|
</div>
|
|
215
228
|
<div class="stat-card">
|
|
216
|
-
<div class="stat-label">Top
|
|
217
|
-
<div class="stat-value" style="font-size:1.1rem;">
|
|
218
|
-
<div class="stat-sub">
|
|
229
|
+
<div class="stat-label">Top recorded blocker</div>
|
|
230
|
+
<div class="stat-value" style="font-size:1.1rem;">No recorded blocker yet</div>
|
|
231
|
+
<div class="stat-sub">top blocker appears only after at least one recorded block</div>
|
|
219
232
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
|
|
220
233
|
</div>
|
|
221
234
|
</div>
|
|
@@ -225,25 +238,25 @@
|
|
|
225
238
|
<div class="stat-card">
|
|
226
239
|
<div class="stat-label">Estimated hours saved</div>
|
|
227
240
|
<div class="stat-value">0.0</div>
|
|
228
|
-
<div class="stat-sub"
|
|
241
|
+
<div class="stat-sub">0 because no block/warn events are recorded</div>
|
|
229
242
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
|
|
230
243
|
</div>
|
|
231
244
|
<div class="stat-card">
|
|
232
245
|
<div class="stat-label">Estimated LLM dollars saved</div>
|
|
233
246
|
<div class="stat-value">$0.00</div>
|
|
234
|
-
<div class="stat-sub">
|
|
247
|
+
<div class="stat-sub">0 because no recorded block events feed this estimate</div>
|
|
235
248
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/token-savings.js">source: token-savings.js</a>
|
|
236
249
|
</div>
|
|
237
250
|
<div class="stat-card">
|
|
238
251
|
<div class="stat-label">Tokens not spent</div>
|
|
239
252
|
<div class="stat-value">0</div>
|
|
240
|
-
<div class="stat-sub">
|
|
253
|
+
<div class="stat-sub">0 because no recorded block events feed this estimate</div>
|
|
241
254
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/token-savings.js">source: token-savings.js</a>
|
|
242
255
|
</div>
|
|
243
256
|
<div class="stat-card">
|
|
244
257
|
<div class="stat-label">Scorer Bayes error</div>
|
|
245
|
-
<div class="stat-value">n/a
|
|
246
|
-
<div class="stat-sub">
|
|
258
|
+
<div class="stat-value">n/a</div>
|
|
259
|
+
<div class="stat-sub">needs both safe and harmful feedback sequences</div>
|
|
247
260
|
<a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/bayes-optimal-gate.js">source: bayes-optimal-gate.js</a>
|
|
248
261
|
</div>
|
|
249
262
|
</div>
|
|
@@ -252,11 +265,11 @@
|
|
|
252
265
|
<div class="method">
|
|
253
266
|
<p><strong>Where the numbers come from.</strong> This page is regenerated from local scripts — no survey data, no hand-edited figures, no third-party attribution. Every number on this page is produced by code in the public <a href="https://github.com/IgorGanapolsky/ThumbGate">ThumbGate repo</a>.</p>
|
|
254
267
|
<ul>
|
|
255
|
-
<li><strong>
|
|
256
|
-
<li><strong>Actions blocked/warned</strong> —
|
|
257
|
-
<li><strong>Hours saved</strong> — conservative 15-minute/incident estimate
|
|
258
|
-
<li><strong>Dollars saved</strong> — blended per-call token estimate (2k input + 600 output) × blocks × 2026-04-15 Anthropic + OpenAI list prices. See <code>scripts/token-savings.js</code> for the full price snapshot.</li>
|
|
259
|
-
<li><strong>Bayes error rate</strong> —
|
|
268
|
+
<li><strong>Configured checks</strong> — union of shipped default rules and the auto-promotion ledger. This is inventory, not proof that the check fired.</li>
|
|
269
|
+
<li><strong>Actions blocked/warned</strong> — recorded intervention counts from gate occurrence data. These are the evidence counters.</li>
|
|
270
|
+
<li><strong>Hours saved</strong> — conservative 15-minute/incident estimate, shown only as a function of recorded block/warn counts.</li>
|
|
271
|
+
<li><strong>Dollars saved</strong> — blended per-call token estimate (2k input + 600 output) × recorded blocks × 2026-04-15 Anthropic + OpenAI list prices. See <code>scripts/token-savings.js</code> for the full price snapshot.</li>
|
|
272
|
+
<li><strong>Bayes error rate</strong> — shown only when the feedback sample contains both safe and harmful outcomes. One-class samples are reported as n/a.</li>
|
|
260
273
|
</ul>
|
|
261
274
|
<p style="margin-top:12px;">Last auto-promotion: none. Regenerated on every release via <code>npm run numbers:generate</code> and on a weekly cadence.</p>
|
|
262
275
|
</div>
|
|
@@ -264,7 +277,7 @@
|
|
|
264
277
|
<div class="cta">
|
|
265
278
|
<a href="https://www.npmjs.com/package/thumbgate">Install ThumbGate — npx thumbgate init</a>
|
|
266
279
|
<div class="footer-note">Prefer the raw feed? See <a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a> or run <code>npm run gate:stats</code> locally.</div>
|
|
267
|
-
<div class="footer-note">Generated at 2026-05-
|
|
280
|
+
<div class="footer-note">Generated at 2026-05-07T14:59:51.816Z UTC.</div>
|
|
268
281
|
</div>
|
|
269
282
|
</main>
|
|
270
283
|
</body>
|
package/scripts/billing.js
CHANGED
|
@@ -1149,7 +1149,8 @@ function loadResolvedRevenueEvents(options = {}) {
|
|
|
1149
1149
|
const derived = deriveRevenueEventFromPaidProviderEvent(entry);
|
|
1150
1150
|
if (!derived) continue;
|
|
1151
1151
|
if (hasRevenueEventMatch(resolved, derived)) continue;
|
|
1152
|
-
|
|
1152
|
+
const priced = resolveGithubMarketplaceRevenueEntry(derived, { annotate: false }).entry;
|
|
1153
|
+
resolved.push(priced);
|
|
1153
1154
|
}
|
|
1154
1155
|
|
|
1155
1156
|
return mergeRevenueEvents(resolved, extraRevenueEvents);
|
|
@@ -1161,6 +1162,9 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
|
|
|
1161
1162
|
const rows = loadJsonlFile(ledgerPath);
|
|
1162
1163
|
const resolvedAt = new Date().toISOString();
|
|
1163
1164
|
const repairs = [];
|
|
1165
|
+
|
|
1166
|
+
// Pass 1: in-place repair of rows already in revenue-events.jsonl that have
|
|
1167
|
+
// unknown amounts but resolvable plan metadata.
|
|
1164
1168
|
const updatedRows = rows.map((entry) => {
|
|
1165
1169
|
const result = resolveGithubMarketplaceRevenueEntry(entry, {
|
|
1166
1170
|
annotate: true,
|
|
@@ -1178,21 +1182,76 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
|
|
|
1178
1182
|
currency: normalizeCurrency(result.entry.currency),
|
|
1179
1183
|
recurringInterval: normalizeText(result.entry.recurringInterval),
|
|
1180
1184
|
pricingSource: normalizeText(metadata.githubMarketplaceAmountSource),
|
|
1185
|
+
source: 'in_place',
|
|
1181
1186
|
});
|
|
1182
1187
|
return result.entry;
|
|
1183
1188
|
});
|
|
1184
1189
|
|
|
1190
|
+
// Pass 2: append rows for funnel-derived paid github_marketplace events that
|
|
1191
|
+
// never landed in the revenue ledger. The webhook handler at handleGithubWebhook
|
|
1192
|
+
// skips appendRevenueEvent when hasRevenueEventMatch is true, so duplicates from
|
|
1193
|
+
// a re-run are already prevented; the only way an order gets here is if the
|
|
1194
|
+
// revenue write was skipped at webhook time (e.g. funnel pre-existed, planPricing
|
|
1195
|
+
// had unknown amount at the time, or the row was created via a different path).
|
|
1196
|
+
const funnelRecords = loadFunnelLedger().filter(
|
|
1197
|
+
(e) =>
|
|
1198
|
+
e &&
|
|
1199
|
+
e.stage === 'paid' &&
|
|
1200
|
+
normalizeText((e.metadata && e.metadata.provider) || e.provider) === 'github_marketplace'
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
for (const funnelEntry of funnelRecords) {
|
|
1204
|
+
const derived = deriveRevenueEventFromPaidProviderEvent(funnelEntry);
|
|
1205
|
+
if (!derived) continue;
|
|
1206
|
+
if (hasRevenueEventMatch(updatedRows, derived)) continue;
|
|
1207
|
+
|
|
1208
|
+
const resolved = resolveGithubMarketplaceRevenueEntry(derived, {
|
|
1209
|
+
annotate: true,
|
|
1210
|
+
resolvedAt,
|
|
1211
|
+
});
|
|
1212
|
+
// Skip funnel-derived rows that still have unknown amounts after resolving —
|
|
1213
|
+
// we don't want to permanently bake amountKnown:false rows when there's no
|
|
1214
|
+
// pricing data to attach. Better to leave them off-disk and keep the
|
|
1215
|
+
// read-time merge in loadResolvedRevenueEvents covering them.
|
|
1216
|
+
if (!resolved.changed || !resolved.entry.amountKnown) continue;
|
|
1217
|
+
|
|
1218
|
+
const persisted = {
|
|
1219
|
+
...resolved.entry,
|
|
1220
|
+
metadata: {
|
|
1221
|
+
...sanitizeMetadata(resolved.entry.metadata),
|
|
1222
|
+
recoveredFromFunnelLedger: true,
|
|
1223
|
+
funnelRecordedAt: normalizeText(funnelEntry.timestamp) || null,
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
updatedRows.push(persisted);
|
|
1227
|
+
|
|
1228
|
+
const metadata = sanitizeMetadata(persisted.metadata);
|
|
1229
|
+
repairs.push({
|
|
1230
|
+
orderId: normalizeText(persisted.orderId),
|
|
1231
|
+
customerId: normalizeText(persisted.customerId),
|
|
1232
|
+
planId: normalizeText(metadata.planId ?? persisted.planId),
|
|
1233
|
+
amountCents: normalizeInteger(persisted.amountCents),
|
|
1234
|
+
currency: normalizeCurrency(persisted.currency),
|
|
1235
|
+
recurringInterval: normalizeText(persisted.recurringInterval),
|
|
1236
|
+
pricingSource: normalizeText(metadata.githubMarketplaceAmountSource),
|
|
1237
|
+
source: 'funnel_derived',
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1185
1241
|
const writeResult = write && repairs.length > 0
|
|
1186
1242
|
? writeJsonlRecords(ledgerPath, updatedRows)
|
|
1187
|
-
: { written: false, rowCount:
|
|
1243
|
+
: { written: false, rowCount: updatedRows.length };
|
|
1188
1244
|
|
|
1189
1245
|
return {
|
|
1190
1246
|
ledgerPath,
|
|
1191
1247
|
write,
|
|
1192
1248
|
wrote: Boolean(writeResult.written),
|
|
1193
1249
|
scanned: rows.length,
|
|
1250
|
+
funnelScanned: funnelRecords.length,
|
|
1194
1251
|
repaired: repairs.length,
|
|
1195
|
-
|
|
1252
|
+
repairedInPlace: repairs.filter((r) => r.source === 'in_place').length,
|
|
1253
|
+
repairedFromFunnel: repairs.filter((r) => r.source === 'funnel_derived').length,
|
|
1254
|
+
unchanged: rows.length - repairs.filter((r) => r.source === 'in_place').length,
|
|
1196
1255
|
repairs,
|
|
1197
1256
|
writeResult,
|
|
1198
1257
|
};
|
package/scripts/gate-stats.js
CHANGED
|
@@ -43,10 +43,12 @@ function calculateStats() {
|
|
|
43
43
|
.filter((g) => g.action === 'warn')
|
|
44
44
|
.reduce((sum, g) => sum + (g.occurrences || 0), 0);
|
|
45
45
|
|
|
46
|
-
// Top blocked gate
|
|
46
|
+
// Top blocked gate. A configured block rule with zero occurrences is not a
|
|
47
|
+
// "top blocker"; only recorded block events should appear here.
|
|
47
48
|
const topBlocked = [...allGates]
|
|
49
|
+
.filter((g) => g.action === 'block' && Number(g.occurrences || 0) > 0)
|
|
48
50
|
.sort((a, b) => (b.occurrences || 0) - (a.occurrences || 0))
|
|
49
|
-
.
|
|
51
|
+
.at(0) || null;
|
|
50
52
|
|
|
51
53
|
// Last promotion event
|
|
52
54
|
const lastPromotion = promotionLog.length > 0
|
|
@@ -58,11 +60,9 @@ function calculateStats() {
|
|
|
58
60
|
const estimatedHoursSaved = (estimatedMinutesSaved / 60).toFixed(1);
|
|
59
61
|
|
|
60
62
|
// Bayes error rate: irreducible error floor of the current scorer given its
|
|
61
|
-
// feature set (tag signatures).
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// and we should add features (file path, recency, commit context) rather
|
|
65
|
-
// than tune thresholds. Null when no feedback sequences have been recorded.
|
|
63
|
+
// feature set (tag signatures). It is only meaningful when the local sample
|
|
64
|
+
// contains both harmful and safe outcomes; an all-negative or all-positive
|
|
65
|
+
// sample can produce a misleading 0.0% floor.
|
|
66
66
|
const bayesErrorRate = tryComputeBayesErrorRate();
|
|
67
67
|
|
|
68
68
|
return {
|
|
@@ -90,12 +90,33 @@ function tryComputeBayesErrorRate() {
|
|
|
90
90
|
.filter(Boolean)
|
|
91
91
|
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
92
92
|
.filter(Boolean);
|
|
93
|
+
if (!hasMixedOutcomeClasses(rows)) return null;
|
|
93
94
|
return computeBayesErrorRate(rows);
|
|
94
95
|
} catch {
|
|
95
96
|
return null;
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
function hasMixedOutcomeClasses(rows) {
|
|
101
|
+
if (!Array.isArray(rows) || rows.length < 2) return false;
|
|
102
|
+
let harmful = 0;
|
|
103
|
+
let safe = 0;
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
if (sequenceIsHarmful(row)) harmful += 1;
|
|
106
|
+
else safe += 1;
|
|
107
|
+
if (harmful > 0 && safe > 0) return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sequenceIsHarmful(row) {
|
|
113
|
+
if (!row || typeof row !== 'object') return false;
|
|
114
|
+
if (typeof row.targetRisk === 'number') return row.targetRisk > 0;
|
|
115
|
+
if (typeof row.accepted === 'boolean' && row.accepted === false) return true;
|
|
116
|
+
const label = String(row.label || row.signal || '').toLowerCase();
|
|
117
|
+
return label === 'negative';
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
function formatLastPromotion(promo) {
|
|
100
121
|
if (!promo) return 'none';
|
|
101
122
|
const ts = promo.timestamp ? new Date(promo.timestamp) : null;
|
|
@@ -125,7 +146,7 @@ function formatStats(stats) {
|
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
function formatBayesErrorRate(rate) {
|
|
128
|
-
if (rate === null || rate === undefined) return 'n/a (
|
|
149
|
+
if (rate === null || rate === undefined) return 'n/a (need both safe and harmful feedback sequences)';
|
|
129
150
|
const pct = (rate * 100).toFixed(1);
|
|
130
151
|
if (rate < 0.02) return `${pct}% — scorer is near-optimal; add features, don't tune thresholds`;
|
|
131
152
|
if (rate < 0.10) return `${pct}% — scorer has modest headroom`;
|
package/scripts/rate-limiter.js
CHANGED
|
@@ -12,28 +12,28 @@ const {
|
|
|
12
12
|
const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
|
|
13
13
|
|
|
14
14
|
// ──────────────────────────────────────────────────────────
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
// Free tier: generous on captures (habit formation) and rules
|
|
16
|
+
// (5 active gates), gated on Pro-only features (recall, search,
|
|
17
|
+
// exports). Dashboard, exports, and unlimited rules drive Pro.
|
|
17
18
|
// ──────────────────────────────────────────────────────────
|
|
18
19
|
const FREE_TIER_LIMITS = {
|
|
19
|
-
capture_feedback: { daily: Infinity, lifetime:
|
|
20
|
-
prevention_rules: { daily: Infinity, lifetime:
|
|
21
|
-
recall: { daily: 0, lifetime: 0,
|
|
22
|
-
search_lessons: { daily: 0, lifetime: 0,
|
|
23
|
-
search_thumbgate: { daily: 0, lifetime: 0,
|
|
24
|
-
commerce_recall: { daily: 0, lifetime: 0,
|
|
25
|
-
export_dpo: { daily: 0, lifetime: 0,
|
|
26
|
-
export_databricks: { daily: 0, lifetime: 0,
|
|
27
|
-
construct_context_pack: { daily: Infinity, lifetime:
|
|
20
|
+
capture_feedback: { daily: Infinity, lifetime: Infinity, label: 'feedback captures' },
|
|
21
|
+
prevention_rules: { daily: Infinity, lifetime: Infinity, label: 'prevention rules generated' },
|
|
22
|
+
recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
|
|
23
|
+
search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
|
|
24
|
+
search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
|
|
25
|
+
commerce_recall: { daily: 0, lifetime: 0, label: 'commerce recalls (Pro only)' },
|
|
26
|
+
export_dpo: { daily: 0, lifetime: 0, label: 'DPO exports (Pro only)' },
|
|
27
|
+
export_databricks: { daily: 0, lifetime: 0, label: 'Databricks exports (Pro only)' },
|
|
28
|
+
construct_context_pack: { daily: Infinity, lifetime: Infinity, label: 'context packs' },
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
const FREE_TIER_MAX_GATES =
|
|
31
|
+
const FREE_TIER_MAX_GATES = 5; // 5 active prevention rules on free; Pro is unlimited
|
|
31
32
|
|
|
32
|
-
const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited
|
|
33
|
+
const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
|
|
33
34
|
|
|
34
35
|
const PAYWALL_MESSAGES = {
|
|
35
|
-
|
|
36
|
-
prevention_rules: 'Free tier includes 1 prevention rule. Your agents need more protection — upgrade to Pro for unlimited rules.',
|
|
36
|
+
prevention_rules: 'Free tier includes 5 active prevention rules. Promote more or unlock unlimited rules with Pro.',
|
|
37
37
|
recall: 'Recall is a Pro feature. Your past feedback is stored locally — upgrade to search and reuse it.',
|
|
38
38
|
search_lessons: 'Lesson search is a Pro feature. Upgrade to find patterns in your agent\'s mistakes.',
|
|
39
39
|
default: 'This feature requires Pro. Start Pro — card required; billed today.',
|
package/src/api/server.js
CHANGED
|
@@ -16,6 +16,8 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
|
|
|
16
16
|
const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/4gM6oHgH2bTw4lH6i73sI0z';
|
|
17
17
|
const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/aFa8wPgH29Lo4lH35V3sI0w';
|
|
18
18
|
const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/7sYfZhgH29LodWhdKz3sI0v';
|
|
19
|
+
const SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/3cI7sLgH25v8dWh5e33sI0o';
|
|
20
|
+
const WORKFLOW_SPRINT_CHECKOUT_URL = 'https://buy.stripe.com/8x25kDcqMaPs9G15e33sI0p';
|
|
19
21
|
|
|
20
22
|
function getPosthogProxyPath(pathname) {
|
|
21
23
|
return pathname.slice('/ingest'.length) || '/';
|
|
@@ -210,6 +212,7 @@ const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
|
|
|
210
212
|
const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
|
|
211
213
|
const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
|
|
212
214
|
const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
|
|
215
|
+
const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
|
|
213
216
|
const PUBLIC_DIR = path.resolve(__dirname, '../../public');
|
|
214
217
|
const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
|
|
215
218
|
const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
|
|
@@ -1587,6 +1590,12 @@ function normalizeTrackedLinkSlug(value) {
|
|
|
1587
1590
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
1588
1591
|
}
|
|
1589
1592
|
|
|
1593
|
+
function normalizePublicPageSlug(value) {
|
|
1594
|
+
return String(value || '')
|
|
1595
|
+
.replace(/\.html$/i, '')
|
|
1596
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1590
1599
|
function getTrackedLinkTarget(slug) {
|
|
1591
1600
|
const normalizedSlug = normalizeTrackedLinkSlug(slug);
|
|
1592
1601
|
return TRACKED_LINK_TARGETS[normalizedSlug]
|
|
@@ -1939,8 +1948,8 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
|
|
|
1939
1948
|
'__CHECKOUT_FALLBACK_URL__': runtimeConfig.checkoutFallbackUrl,
|
|
1940
1949
|
'__PRO_PRICE_DOLLARS__': runtimeConfig.proPriceDollars,
|
|
1941
1950
|
'__PRO_PRICE_LABEL__': runtimeConfig.proPriceLabel,
|
|
1942
|
-
'__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl ||
|
|
1943
|
-
'__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl ||
|
|
1951
|
+
'__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
|
|
1952
|
+
'__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
|
|
1944
1953
|
'__SPRINT_DIAGNOSTIC_PRICE_DOLLARS__': runtimeConfig.sprintDiagnosticPriceDollars || 499,
|
|
1945
1954
|
'__WORKFLOW_SPRINT_PRICE_DOLLARS__': runtimeConfig.workflowSprintPriceDollars || 1500,
|
|
1946
1955
|
'__GA_MEASUREMENT_ID__': runtimeConfig.gaMeasurementId || '',
|
|
@@ -4193,6 +4202,15 @@ async function addContext(){
|
|
|
4193
4202
|
return;
|
|
4194
4203
|
}
|
|
4195
4204
|
|
|
4205
|
+
if (isGetLikeRequest && pathname === '/lessons/') {
|
|
4206
|
+
res.writeHead(302, {
|
|
4207
|
+
Location: '/lessons',
|
|
4208
|
+
'Cache-Control': 'no-store',
|
|
4209
|
+
});
|
|
4210
|
+
res.end();
|
|
4211
|
+
return;
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4196
4214
|
if (isGetLikeRequest && pathname === '/lessons') {
|
|
4197
4215
|
try {
|
|
4198
4216
|
const html = loadLessonsPageHtml(req, expectedApiKey);
|
|
@@ -4245,7 +4263,7 @@ async function addContext(){
|
|
|
4245
4263
|
return;
|
|
4246
4264
|
}
|
|
4247
4265
|
|
|
4248
|
-
if (isGetLikeRequest && pathname === '/guide') {
|
|
4266
|
+
if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
|
|
4249
4267
|
try {
|
|
4250
4268
|
const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
|
|
4251
4269
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
@@ -4255,7 +4273,7 @@ async function addContext(){
|
|
|
4255
4273
|
return;
|
|
4256
4274
|
}
|
|
4257
4275
|
|
|
4258
|
-
if (isGetLikeRequest && pathname === '/codex-plugin') {
|
|
4276
|
+
if (isGetLikeRequest && (pathname === '/codex-plugin' || pathname === '/codex-plugin.html')) {
|
|
4259
4277
|
try {
|
|
4260
4278
|
const html = fs.readFileSync(CODEX_PLUGIN_PAGE_PATH, 'utf-8');
|
|
4261
4279
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
@@ -4265,7 +4283,7 @@ async function addContext(){
|
|
|
4265
4283
|
return;
|
|
4266
4284
|
}
|
|
4267
4285
|
|
|
4268
|
-
if (isGetLikeRequest && pathname === '/compare') {
|
|
4286
|
+
if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
|
|
4269
4287
|
try {
|
|
4270
4288
|
const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
|
|
4271
4289
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
@@ -4275,7 +4293,7 @@ async function addContext(){
|
|
|
4275
4293
|
return;
|
|
4276
4294
|
}
|
|
4277
4295
|
|
|
4278
|
-
if (isGetLikeRequest && pathname === '/blog') {
|
|
4296
|
+
if (isGetLikeRequest && (pathname === '/blog' || pathname === '/blog.html')) {
|
|
4279
4297
|
try {
|
|
4280
4298
|
const blogPath = path.resolve(__dirname, '../../public/blog.html');
|
|
4281
4299
|
const html = fs.readFileSync(blogPath, 'utf-8');
|
|
@@ -4286,7 +4304,7 @@ async function addContext(){
|
|
|
4286
4304
|
return;
|
|
4287
4305
|
}
|
|
4288
4306
|
|
|
4289
|
-
if (isGetLikeRequest && pathname === '/learn') {
|
|
4307
|
+
if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
|
|
4290
4308
|
try {
|
|
4291
4309
|
const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
|
|
4292
4310
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
@@ -4296,6 +4314,24 @@ async function addContext(){
|
|
|
4296
4314
|
return;
|
|
4297
4315
|
}
|
|
4298
4316
|
|
|
4317
|
+
if (isGetLikeRequest && (pathname === '/guides' || pathname === '/guides/' || pathname === '/guides.html')) {
|
|
4318
|
+
res.writeHead(302, {
|
|
4319
|
+
Location: '/learn',
|
|
4320
|
+
'Cache-Control': 'no-store',
|
|
4321
|
+
});
|
|
4322
|
+
res.end();
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
if (isGetLikeRequest && (pathname === '/services' || pathname === '/services.html')) {
|
|
4327
|
+
res.writeHead(302, {
|
|
4328
|
+
Location: '/#workflow-sprint-intake',
|
|
4329
|
+
'Cache-Control': 'no-store',
|
|
4330
|
+
});
|
|
4331
|
+
res.end();
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4299
4335
|
if (isGetLikeRequest && (pathname === '/numbers' || pathname === '/numbers.html')) {
|
|
4300
4336
|
// Route through servePublicMarketingPage so landing_page_view telemetry
|
|
4301
4337
|
// + funnel-events.jsonl `discovery/landing_view` get captured with UTM
|
|
@@ -4331,7 +4367,7 @@ async function addContext(){
|
|
|
4331
4367
|
|
|
4332
4368
|
if (isGetLikeRequest && pathname.startsWith('/learn/')) {
|
|
4333
4369
|
try {
|
|
4334
|
-
const slug = pathname.replace('/learn/', '')
|
|
4370
|
+
const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
|
|
4335
4371
|
const articlePath = path.join(LEARN_DIR, `${slug}.html`);
|
|
4336
4372
|
if (!articlePath.startsWith(LEARN_DIR)) {
|
|
4337
4373
|
sendJson(res, 403, { error: 'Forbidden' });
|
|
@@ -4347,7 +4383,7 @@ async function addContext(){
|
|
|
4347
4383
|
|
|
4348
4384
|
if (isGetLikeRequest && pathname.startsWith('/guides/')) {
|
|
4349
4385
|
try {
|
|
4350
|
-
const slug = pathname.replace('/guides/', '')
|
|
4386
|
+
const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
|
|
4351
4387
|
const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
|
|
4352
4388
|
if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
|
|
4353
4389
|
const html = fs.readFileSync(guidePath, 'utf-8');
|
|
@@ -4358,7 +4394,7 @@ async function addContext(){
|
|
|
4358
4394
|
|
|
4359
4395
|
if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
|
|
4360
4396
|
try {
|
|
4361
|
-
const slug = pathname.replace('/compare/', '')
|
|
4397
|
+
const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
|
|
4362
4398
|
const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
|
|
4363
4399
|
if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
|
|
4364
4400
|
const html = fs.readFileSync(comparePath, 'utf-8');
|
|
@@ -4367,6 +4403,17 @@ async function addContext(){
|
|
|
4367
4403
|
return;
|
|
4368
4404
|
}
|
|
4369
4405
|
|
|
4406
|
+
if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
|
|
4407
|
+
try {
|
|
4408
|
+
const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
|
|
4409
|
+
const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
|
|
4410
|
+
if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
|
|
4411
|
+
const html = fs.readFileSync(useCasePath, 'utf-8');
|
|
4412
|
+
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4413
|
+
} catch { sendJson(res, 404, { error: 'Use case not found' }); }
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4370
4417
|
if (isGetLikeRequest && pathname.startsWith('/assets/')) {
|
|
4371
4418
|
const rel = pathname.slice('/assets/'.length);
|
|
4372
4419
|
const resolved = path.resolve(PUBLIC_ASSETS_DIR, rel);
|
|
@@ -4500,24 +4547,28 @@ async function addContext(){
|
|
|
4500
4547
|
ctaPlacement: 'checkout_interstitial',
|
|
4501
4548
|
planId: 'workflow_teardown',
|
|
4502
4549
|
});
|
|
4503
|
-
const diagnosticCheckoutHref =
|
|
4504
|
-
|
|
4550
|
+
const diagnosticCheckoutHref = buildCheckoutIntentHref(
|
|
4551
|
+
hostedConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
|
|
4552
|
+
analyticsMetadata,
|
|
4553
|
+
{
|
|
4505
4554
|
utmMedium: 'checkout_interstitial_paid_path',
|
|
4506
4555
|
utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_diagnostic',
|
|
4507
4556
|
ctaId: 'checkout_interstitial_sprint_diagnostic_checkout',
|
|
4508
4557
|
ctaPlacement: 'checkout_interstitial',
|
|
4509
4558
|
planId: 'sprint_diagnostic',
|
|
4510
|
-
}
|
|
4511
|
-
|
|
4512
|
-
const sprintCheckoutHref =
|
|
4513
|
-
|
|
4559
|
+
}
|
|
4560
|
+
);
|
|
4561
|
+
const sprintCheckoutHref = buildCheckoutIntentHref(
|
|
4562
|
+
hostedConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
|
|
4563
|
+
analyticsMetadata,
|
|
4564
|
+
{
|
|
4514
4565
|
utmMedium: 'checkout_interstitial_paid_path',
|
|
4515
4566
|
utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_workflow_sprint',
|
|
4516
4567
|
ctaId: 'checkout_interstitial_workflow_sprint_checkout',
|
|
4517
4568
|
ctaPlacement: 'checkout_interstitial',
|
|
4518
4569
|
planId: 'workflow_sprint',
|
|
4519
|
-
}
|
|
4520
|
-
|
|
4570
|
+
}
|
|
4571
|
+
);
|
|
4521
4572
|
const html = renderCheckoutIntentPage({
|
|
4522
4573
|
confirmHref: buildCheckoutConfirmHref(parsed),
|
|
4523
4574
|
firstRuleCheckoutHref,
|