thumbgate 1.5.8 → 1.6.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/mcp/server-card.json +1 -1
- package/CHANGELOG.md +198 -0
- package/README.md +7 -6
- package/adapters/README.md +1 -1
- package/adapters/chatgpt/openapi.yaml +25 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +61 -5
- package/openapi/openapi.yaml +25 -0
- package/package.json +12 -3
- package/public/codex-plugin.html +277 -0
- package/public/dashboard.html +141 -13
- package/public/index.html +92 -34
- package/public/learn.html +13 -2
- package/public/lessons.html +5 -2
- package/public/pro.html +8 -1
- package/scripts/auto-wire-hooks.js +10 -5
- package/scripts/billing.js +503 -8
- package/scripts/contextfs.js +1 -1
- package/scripts/dashboard.js +236 -0
- package/scripts/gates-engine.js +153 -2
- package/scripts/hook-runtime.js +42 -0
- package/scripts/llm-client.js +25 -10
- package/scripts/mailer/index.js +13 -0
- package/scripts/mailer/resend-mailer.js +350 -0
- package/scripts/mcp-config.js +13 -0
- package/scripts/published-cli.js +8 -0
- package/scripts/seo-gsd.js +118 -4
- package/scripts/statusline.sh +8 -0
- package/scripts/vector-store.js +21 -7
- package/src/api/server.js +112 -7
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/mailer/resend-mailer.js
|
|
5
|
+
*
|
|
6
|
+
* Resend-backed transactional email sender for ThumbGate.
|
|
7
|
+
*
|
|
8
|
+
* Design goals:
|
|
9
|
+
* - Zero-dep: uses global `fetch` (Node 18+) to hit https://api.resend.com/emails.
|
|
10
|
+
* - Gracefully optional: if RESEND_API_KEY is unset, sends are skipped with a
|
|
11
|
+
* logged warning and the caller receives `{ sent: false, reason: 'no_api_key' }`.
|
|
12
|
+
* This prevents the Stripe webhook from failing when email is not configured.
|
|
13
|
+
* - Testable: accepts an injectable `fetch` implementation via opts.fetchImpl.
|
|
14
|
+
* - CAN-SPAM compliant: every commercial email carries business name, physical
|
|
15
|
+
* address, and a functional unsubscribe method.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const PRODUCT_NAME = 'ThumbGate Pro';
|
|
19
|
+
const DASHBOARD_URL = 'https://thumbgate-production.up.railway.app/dashboard';
|
|
20
|
+
const SUPPORT_EMAIL = 'hello@thumbgate.app';
|
|
21
|
+
const DEFAULT_FROM = 'onboarding@resend.dev';
|
|
22
|
+
const DEFAULT_REPLY_TO = 'hello@thumbgate.app';
|
|
23
|
+
const DEFAULT_UNSUBSCRIBE_EMAIL = 'unsubscribe@thumbgate.app';
|
|
24
|
+
const DEFAULT_BUSINESS_NAME = 'Max Smith KDP LLC';
|
|
25
|
+
// CAN-SPAM requires a physical mailing address. Override via THUMBGATE_BUSINESS_ADDRESS.
|
|
26
|
+
const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94114';
|
|
27
|
+
// Hosted PNG that email clients (Gmail, Outlook) will proxy. SVG is stripped from most email HTML.
|
|
28
|
+
const BRAND_MARK_URL = 'https://thumbgate-production.up.railway.app/thumbgate-icon.png';
|
|
29
|
+
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
30
|
+
const TRIAL_LENGTH_DAYS = 7;
|
|
31
|
+
|
|
32
|
+
function getApiKey() {
|
|
33
|
+
return process.env.RESEND_API_KEY || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getFromAddress() {
|
|
37
|
+
return process.env.THUMBGATE_TRIAL_EMAIL_FROM || process.env.RESEND_FROM_EMAIL || DEFAULT_FROM;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getReplyTo() {
|
|
41
|
+
return process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || DEFAULT_REPLY_TO;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getUnsubscribeEmail() {
|
|
45
|
+
return process.env.THUMBGATE_UNSUBSCRIBE_EMAIL || DEFAULT_UNSUBSCRIBE_EMAIL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getBusinessName() {
|
|
49
|
+
return process.env.THUMBGATE_BUSINESS_NAME || DEFAULT_BUSINESS_NAME;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getBusinessAddress() {
|
|
53
|
+
return process.env.THUMBGATE_BUSINESS_ADDRESS || DEFAULT_BUSINESS_ADDRESS;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isNonEmptyString(value) {
|
|
57
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
|
|
62
|
+
* missing. Never throws on network errors; returns a structured result instead.
|
|
63
|
+
*/
|
|
64
|
+
async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl } = {}) {
|
|
65
|
+
if (!isNonEmptyString(to)) throw new Error('sendEmail: `to` is required');
|
|
66
|
+
if (!isNonEmptyString(subject)) throw new Error('sendEmail: `subject` is required');
|
|
67
|
+
if (!isNonEmptyString(html) && !isNonEmptyString(text)) {
|
|
68
|
+
throw new Error('sendEmail: at least one of `html` or `text` is required');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const apiKey = getApiKey();
|
|
72
|
+
if (!apiKey) {
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.warn('[mailer] RESEND_API_KEY not set — skipping send to', to);
|
|
75
|
+
return { sent: false, reason: 'no_api_key' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const payload = {
|
|
79
|
+
from: from || getFromAddress(),
|
|
80
|
+
to: Array.isArray(to) ? to : [to],
|
|
81
|
+
subject,
|
|
82
|
+
reply_to: replyTo || getReplyTo(),
|
|
83
|
+
};
|
|
84
|
+
if (isNonEmptyString(html)) payload.html = html;
|
|
85
|
+
if (isNonEmptyString(text)) payload.text = text;
|
|
86
|
+
|
|
87
|
+
const fetcher = fetchImpl || globalThis.fetch;
|
|
88
|
+
if (typeof fetcher !== 'function') {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn('[mailer] global fetch not available — skipping send');
|
|
91
|
+
return { sent: false, reason: 'no_fetch' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetcher(RESEND_ENDPOINT, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
Authorization: `Bearer ${apiKey}`,
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(payload),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const bodyText = typeof res.text === 'function' ? await res.text() : '';
|
|
105
|
+
let bodyJson = null;
|
|
106
|
+
if (bodyText) {
|
|
107
|
+
try { bodyJson = JSON.parse(bodyText); } catch (_) { /* leave as text */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
|
|
113
|
+
return { sent: false, reason: 'api_error', status: res.status, body: bodyJson || bodyText };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { sent: true, id: bodyJson && bodyJson.id ? bodyJson.id : null, status: res.status };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.warn('[mailer] send failed:', err && err.message ? err.message : err);
|
|
120
|
+
return { sent: false, reason: 'exception', error: err && err.message ? err.message : String(err) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function escapeHtml(value) {
|
|
125
|
+
return String(value)
|
|
126
|
+
.replace(/&/g, '&')
|
|
127
|
+
.replace(/</g, '<')
|
|
128
|
+
.replace(/>/g, '>')
|
|
129
|
+
.replace(/"/g, '"')
|
|
130
|
+
.replace(/'/g, ''');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function firstName(full) {
|
|
134
|
+
if (!isNonEmptyString(full)) return '';
|
|
135
|
+
const trimmed = full.trim();
|
|
136
|
+
const first = trimmed.split(/\s+/)[0] || '';
|
|
137
|
+
// Never use email-looking strings as a name.
|
|
138
|
+
if (/@/.test(first)) return '';
|
|
139
|
+
return first;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatTrialEndDate(trialEndAt) {
|
|
143
|
+
let d;
|
|
144
|
+
if (trialEndAt instanceof Date) d = trialEndAt;
|
|
145
|
+
else if (typeof trialEndAt === 'number') d = new Date(trialEndAt);
|
|
146
|
+
else if (typeof trialEndAt === 'string' && trialEndAt) d = new Date(trialEndAt);
|
|
147
|
+
else {
|
|
148
|
+
d = new Date();
|
|
149
|
+
d.setUTCDate(d.getUTCDate() + TRIAL_LENGTH_DAYS);
|
|
150
|
+
}
|
|
151
|
+
if (Number.isNaN(d.getTime())) {
|
|
152
|
+
d = new Date();
|
|
153
|
+
d.setUTCDate(d.getUTCDate() + TRIAL_LENGTH_DAYS);
|
|
154
|
+
}
|
|
155
|
+
// Render as "Apr 24, 2026" (UTC) — avoids locale surprises on server.
|
|
156
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
157
|
+
const month = months[d.getUTCMonth()];
|
|
158
|
+
const day = d.getUTCDate();
|
|
159
|
+
const year = d.getUTCFullYear();
|
|
160
|
+
return `${month} ${day}, ${year}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialEndAt } = {}) {
|
|
164
|
+
const activationCommand = `npx thumbgate pro --activate --key=${licenseKey}`;
|
|
165
|
+
const name = firstName(customerName);
|
|
166
|
+
const greeting = name ? `Hi ${name},` : 'Hi there,';
|
|
167
|
+
const trialEndLabel = formatTrialEndDate(trialEndAt);
|
|
168
|
+
const headline = 'Your ThumbGate Pro trial is live.';
|
|
169
|
+
const subhead = `You have 7 days of Pro access. Trial ends ${trialEndLabel}.`;
|
|
170
|
+
const description =
|
|
171
|
+
'ThumbGate turns thumbs up/down feedback into Pre-Action Gates that stop repeated AI coding mistakes ' +
|
|
172
|
+
'before the next tool call. Lessons stay on your machine. Repeated failures become Reliability Gateway blocks.';
|
|
173
|
+
const exampleFeedback =
|
|
174
|
+
'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
|
|
175
|
+
const proofUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
|
|
176
|
+
const unsubscribeEmail = getUnsubscribeEmail();
|
|
177
|
+
const businessName = getBusinessName();
|
|
178
|
+
const businessAddress = getBusinessAddress();
|
|
179
|
+
const unsubscribeMailto = `mailto:${unsubscribeEmail}?subject=unsubscribe&body=Please%20remove%20me%20from%20ThumbGate%20emails.`;
|
|
180
|
+
const postscript =
|
|
181
|
+
`P.S. The first 10 minutes are the best time to catch your agent's most-repeated failure. ` +
|
|
182
|
+
`Open the dashboard, give one concrete thumbs down on a mistake you've seen twice, and watch ThumbGate build the gate.`;
|
|
183
|
+
|
|
184
|
+
const text = [
|
|
185
|
+
greeting,
|
|
186
|
+
'',
|
|
187
|
+
headline,
|
|
188
|
+
subhead,
|
|
189
|
+
'',
|
|
190
|
+
description,
|
|
191
|
+
'',
|
|
192
|
+
'Your first 10 minutes',
|
|
193
|
+
'1. Activate Pro locally:',
|
|
194
|
+
` ${activationCommand}`,
|
|
195
|
+
'',
|
|
196
|
+
`2. Open your dashboard: ${DASHBOARD_URL}`,
|
|
197
|
+
'',
|
|
198
|
+
'3. Give one concrete thumbs up or thumbs down:',
|
|
199
|
+
` ${exampleFeedback}`,
|
|
200
|
+
'',
|
|
201
|
+
'Your trial key (save this):',
|
|
202
|
+
` ${licenseKey}`,
|
|
203
|
+
'',
|
|
204
|
+
`Verification evidence: ${proofUrl}`,
|
|
205
|
+
'',
|
|
206
|
+
postscript,
|
|
207
|
+
'',
|
|
208
|
+
`Questions? Just reply to this email or write ${SUPPORT_EMAIL}.`,
|
|
209
|
+
'',
|
|
210
|
+
'— Igor, founder of ThumbGate',
|
|
211
|
+
'',
|
|
212
|
+
'---',
|
|
213
|
+
`You're getting this because you started a ${PRODUCT_NAME} trial. Don't want these emails? Unsubscribe: ${unsubscribeEmail}`,
|
|
214
|
+
`${businessName} · ${businessAddress}`,
|
|
215
|
+
].join('\n');
|
|
216
|
+
|
|
217
|
+
const safeKey = escapeHtml(licenseKey);
|
|
218
|
+
const safeCmd = escapeHtml(activationCommand);
|
|
219
|
+
const safeGreeting = escapeHtml(greeting);
|
|
220
|
+
const safeSubhead = escapeHtml(subhead);
|
|
221
|
+
const safeHeadline = escapeHtml(headline);
|
|
222
|
+
const safeDescription = escapeHtml(description);
|
|
223
|
+
const safeExample = escapeHtml(exampleFeedback);
|
|
224
|
+
const safePostscript = escapeHtml(postscript);
|
|
225
|
+
const safeBusinessName = escapeHtml(businessName);
|
|
226
|
+
const safeBusinessAddress = escapeHtml(businessAddress);
|
|
227
|
+
const safeUnsubscribeEmail = escapeHtml(unsubscribeEmail);
|
|
228
|
+
const safeUnsubscribeMailto = escapeHtml(unsubscribeMailto);
|
|
229
|
+
const safeCustomer = customerId ? escapeHtml(customerId) : '';
|
|
230
|
+
|
|
231
|
+
const html = `<!doctype html>
|
|
232
|
+
<html>
|
|
233
|
+
<body style="margin:0;background:#f5f7fb;padding:28px 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#17212b;">
|
|
234
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Activate Pro in one command, open the dashboard, and start blocking repeated AI coding mistakes.</div>
|
|
235
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
|
|
236
|
+
<tr>
|
|
237
|
+
<td align="center">
|
|
238
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;max-width:640px;background:#ffffff;border:1px solid #d8e2ea;border-radius:10px;overflow:hidden;">
|
|
239
|
+
<tr>
|
|
240
|
+
<td style="background:#071115;padding:24px 28px;color:#e7fbff;">
|
|
241
|
+
<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
|
242
|
+
<tr>
|
|
243
|
+
<td style="vertical-align:middle;padding-right:12px;">
|
|
244
|
+
<img src="${BRAND_MARK_URL}" width="40" height="40" alt="ThumbGate" style="display:block;border-radius:8px;">
|
|
245
|
+
</td>
|
|
246
|
+
<td style="vertical-align:middle;">
|
|
247
|
+
<div style="font-size:13px;font-weight:700;letter-spacing:0.02em;text-transform:uppercase;color:#73d4e9;">${escapeHtml(PRODUCT_NAME)}</div>
|
|
248
|
+
</td>
|
|
249
|
+
</tr>
|
|
250
|
+
</table>
|
|
251
|
+
<h1 style="margin:16px 0 8px;font-size:26px;line-height:1.2;color:#ffffff;">${safeHeadline}</h1>
|
|
252
|
+
<p style="margin:0;font-size:14px;line-height:1.5;color:#9cbac4;">${safeSubhead}</p>
|
|
253
|
+
</td>
|
|
254
|
+
</tr>
|
|
255
|
+
<tr>
|
|
256
|
+
<td style="padding:26px 28px 6px;">
|
|
257
|
+
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#17212b;">${safeGreeting}</p>
|
|
258
|
+
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#344451;">${safeDescription}</p>
|
|
259
|
+
<p style="margin:0 0 24px;">
|
|
260
|
+
<a href="${DASHBOARD_URL}" style="display:inline-block;background:#45bfd8;color:#061015;text-decoration:none;font-weight:700;padding:12px 22px;border-radius:6px;font-size:15px;">Open your dashboard</a>
|
|
261
|
+
</p>
|
|
262
|
+
</td>
|
|
263
|
+
</tr>
|
|
264
|
+
<tr>
|
|
265
|
+
<td style="padding:0 28px 10px;">
|
|
266
|
+
<h2 style="margin:0 0 10px;font-size:17px;line-height:1.3;color:#17212b;">Your first 10 minutes</h2>
|
|
267
|
+
<p style="margin:0 0 8px;font-size:14px;line-height:1.55;color:#344451;"><strong>1. Activate Pro locally</strong></p>
|
|
268
|
+
<pre style="margin:0 0 20px;background:#081016;color:#d8f7e4;border:1px solid #23343d;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeCmd}</code></pre>
|
|
269
|
+
|
|
270
|
+
<p style="margin:0 0 8px;font-size:14px;line-height:1.55;color:#344451;"><strong>2. Save your trial key</strong></p>
|
|
271
|
+
<pre style="margin:0 0 20px;background:#eef6f7;color:#0b343c;border:1px solid #c7e2e7;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeKey}</code></pre>
|
|
272
|
+
|
|
273
|
+
<p style="margin:0 0 8px;font-size:14px;line-height:1.55;color:#344451;"><strong>3. Give one concrete thumbs up or thumbs down</strong></p>
|
|
274
|
+
<p style="margin:0 0 8px;font-size:13px;line-height:1.55;color:#526273;">Start with the failure you most want your agent to stop repeating.</p>
|
|
275
|
+
<pre style="margin:0 0 22px;background:#f1fff2;color:#22602b;border:1px solid #bae7c0;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeExample}</code></pre>
|
|
276
|
+
</td>
|
|
277
|
+
</tr>
|
|
278
|
+
<tr>
|
|
279
|
+
<td style="padding:6px 28px 22px;">
|
|
280
|
+
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#344451;font-style:italic;">${safePostscript}</p>
|
|
281
|
+
<p style="margin:0 0 4px;font-size:14px;line-height:1.6;color:#17212b;">— Igor, founder of ThumbGate</p>
|
|
282
|
+
<p style="margin:0;font-size:13px;line-height:1.55;color:#526273;">
|
|
283
|
+
Questions? Just reply to this email or write
|
|
284
|
+
<a href="mailto:${SUPPORT_EMAIL}" style="color:#087a91;">${SUPPORT_EMAIL}</a>.
|
|
285
|
+
</p>
|
|
286
|
+
</td>
|
|
287
|
+
</tr>
|
|
288
|
+
<tr>
|
|
289
|
+
<td style="padding:16px 28px 22px;border-top:1px solid #e2e8ec;background:#fafbfc;">
|
|
290
|
+
<p style="margin:0 0 6px;font-size:12px;line-height:1.5;color:#7a8790;">
|
|
291
|
+
You're getting this one-time email because you started a ${escapeHtml(PRODUCT_NAME)} trial.
|
|
292
|
+
<a href="${safeUnsubscribeMailto}" style="color:#7a8790;text-decoration:underline;">Unsubscribe</a>
|
|
293
|
+
(${safeUnsubscribeEmail}).
|
|
294
|
+
</p>
|
|
295
|
+
<p style="margin:0;font-size:12px;line-height:1.5;color:#7a8790;">
|
|
296
|
+
${safeBusinessName} · ${safeBusinessAddress}
|
|
297
|
+
</p>
|
|
298
|
+
${safeCustomer ? `<p style="margin:8px 0 0;font-size:11px;color:#a0abb2;">Customer ID (for support): <code>${safeCustomer}</code></p>` : ''}
|
|
299
|
+
</td>
|
|
300
|
+
</tr>
|
|
301
|
+
</table>
|
|
302
|
+
</td>
|
|
303
|
+
</tr>
|
|
304
|
+
</table>
|
|
305
|
+
</body>
|
|
306
|
+
</html>`;
|
|
307
|
+
|
|
308
|
+
return { html, text, activationCommand, trialEndLabel, greeting, unsubscribeEmail, businessName, businessAddress };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* High-level helper: send the trial / checkout welcome email with the license key.
|
|
313
|
+
*
|
|
314
|
+
* Accepts optional `customerName` (used for greeting) and `trialEndAt`
|
|
315
|
+
* (Date | number | ISO string — used to display the exact expiry date).
|
|
316
|
+
*
|
|
317
|
+
* Never throws on send failures (beyond input validation); the Stripe webhook
|
|
318
|
+
* must keep working even if email breaks.
|
|
319
|
+
*/
|
|
320
|
+
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl } = {}) {
|
|
321
|
+
if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
|
|
322
|
+
if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
|
|
323
|
+
|
|
324
|
+
const { html, text } = renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialEndAt });
|
|
325
|
+
const name = firstName(customerName);
|
|
326
|
+
const subject = name
|
|
327
|
+
? `${name}, your ThumbGate Pro key is inside`
|
|
328
|
+
: 'Your ThumbGate Pro key is inside';
|
|
329
|
+
|
|
330
|
+
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
sendEmail,
|
|
335
|
+
sendTrialWelcomeEmail,
|
|
336
|
+
renderTrialWelcomeBodies,
|
|
337
|
+
_constants: {
|
|
338
|
+
PRODUCT_NAME,
|
|
339
|
+
DASHBOARD_URL,
|
|
340
|
+
SUPPORT_EMAIL,
|
|
341
|
+
DEFAULT_FROM,
|
|
342
|
+
DEFAULT_REPLY_TO,
|
|
343
|
+
DEFAULT_UNSUBSCRIBE_EMAIL,
|
|
344
|
+
DEFAULT_BUSINESS_NAME,
|
|
345
|
+
DEFAULT_BUSINESS_ADDRESS,
|
|
346
|
+
BRAND_MARK_URL,
|
|
347
|
+
RESEND_ENDPOINT,
|
|
348
|
+
TRIAL_LENGTH_DAYS,
|
|
349
|
+
},
|
|
350
|
+
};
|
package/scripts/mcp-config.js
CHANGED
|
@@ -106,6 +106,17 @@ function portableMcpEntry(pkgVersion) {
|
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
function codexAutoUpdateCliEntry(commandArgs = []) {
|
|
110
|
+
return {
|
|
111
|
+
command: 'sh',
|
|
112
|
+
args: ['-lc', publishedCliShellCommand('latest', commandArgs, { preferInstalled: false })],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function codexAutoUpdateMcpEntry() {
|
|
117
|
+
return codexAutoUpdateCliEntry(['serve']);
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
function localMcpEntry(pkgRoot, scope = 'project') {
|
|
110
121
|
return {
|
|
111
122
|
command: 'node',
|
|
@@ -201,4 +212,6 @@ module.exports = {
|
|
|
201
212
|
resolveLocalServerPath,
|
|
202
213
|
resolveMcpEntry,
|
|
203
214
|
resolveStableSourceRoot,
|
|
215
|
+
codexAutoUpdateCliEntry,
|
|
216
|
+
codexAutoUpdateMcpEntry,
|
|
204
217
|
};
|
package/scripts/published-cli.js
CHANGED
|
@@ -37,6 +37,14 @@ function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
|
|
|
37
37
|
const escapedArgs = commandArgs.map(shellQuote).join(' ');
|
|
38
38
|
const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
|
|
39
39
|
const installPath = `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
|
|
40
|
+
if (options.preferInstalled === false) {
|
|
41
|
+
const packageSpec = `thumbgate@${pkgVersion}`;
|
|
42
|
+
return [
|
|
43
|
+
`mkdir -p ${shellQuote(prefixDir)}`,
|
|
44
|
+
`npm "install" "--prefix" ${shellQuote(prefixDir)} "--no-save" "--omit=dev" ${shellQuote(packageSpec)} >/dev/null 2>&1`,
|
|
45
|
+
`exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`,
|
|
46
|
+
].join(' && ');
|
|
47
|
+
}
|
|
40
48
|
return `${fastPath} || ${installPath}`;
|
|
41
49
|
}
|
|
42
50
|
|
package/scripts/seo-gsd.js
CHANGED
|
@@ -61,6 +61,12 @@ const HIGH_ROI_QUERY_SEEDS = [
|
|
|
61
61
|
source: 'seed',
|
|
62
62
|
notes: 'Broader category demand that feeds comparison and guide pages.',
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
query: 'autoresearch agent safety',
|
|
66
|
+
businessValue: 89,
|
|
67
|
+
source: 'seed',
|
|
68
|
+
notes: 'Emerging self-improving agent query where ThumbGate can own the safety and proof-control wedge.',
|
|
69
|
+
},
|
|
64
70
|
{
|
|
65
71
|
query: 'stop ai coding agents from repeating mistakes',
|
|
66
72
|
businessValue: 88,
|
|
@@ -93,6 +99,45 @@ const HIGH_ROI_QUERY_SEEDS = [
|
|
|
93
99
|
},
|
|
94
100
|
];
|
|
95
101
|
|
|
102
|
+
function guideBlueprint({
|
|
103
|
+
query,
|
|
104
|
+
path,
|
|
105
|
+
pillar,
|
|
106
|
+
title,
|
|
107
|
+
heroTitle,
|
|
108
|
+
heroSummary,
|
|
109
|
+
takeaways,
|
|
110
|
+
sections,
|
|
111
|
+
faq,
|
|
112
|
+
relatedPaths,
|
|
113
|
+
}) {
|
|
114
|
+
return {
|
|
115
|
+
query,
|
|
116
|
+
path,
|
|
117
|
+
pageType: 'guide',
|
|
118
|
+
pillar,
|
|
119
|
+
title,
|
|
120
|
+
heroTitle,
|
|
121
|
+
heroSummary,
|
|
122
|
+
takeaways,
|
|
123
|
+
sections,
|
|
124
|
+
faq,
|
|
125
|
+
relatedPaths,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function paragraphs(heading, entries) {
|
|
130
|
+
return { heading, paragraphs: entries };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bullets(heading, entries) {
|
|
134
|
+
return { heading, bullets: entries };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function answer(question, text) {
|
|
138
|
+
return { question, answer: text };
|
|
139
|
+
}
|
|
140
|
+
|
|
96
141
|
const PAGE_BLUEPRINTS = [
|
|
97
142
|
{
|
|
98
143
|
query: 'thumbgate vs speclock',
|
|
@@ -484,6 +529,54 @@ const PAGE_BLUEPRINTS = [
|
|
|
484
529
|
],
|
|
485
530
|
relatedPaths: ['/compare/mem0', '/guides/stop-repeated-ai-agent-mistakes'],
|
|
486
531
|
},
|
|
532
|
+
guideBlueprint({
|
|
533
|
+
query: 'autoresearch agent safety',
|
|
534
|
+
path: '/guides/autoresearch-agent-safety',
|
|
535
|
+
pillar: 'pre-action-gates',
|
|
536
|
+
title: 'Autoresearch Agent Safety | Gates for Self-Improving Coding Agents',
|
|
537
|
+
heroTitle: 'Autoresearch Agent Safety for Self-Improving Coding Agents',
|
|
538
|
+
heroSummary: 'Autoresearch-style loops can search for better code, but they need gates for holdout tests, proof trails, reward hacking, and unsafe self-improvement.',
|
|
539
|
+
takeaways: [
|
|
540
|
+
'Self-improving coding loops need a control plane before they promote their own wins.',
|
|
541
|
+
'ThumbGate turns failed experiment reviews into prevention rules and pre-action gates.',
|
|
542
|
+
'The sales wedge is concrete: let the agent search, but gate the evidence before it accepts a variant.',
|
|
543
|
+
],
|
|
544
|
+
sections: [
|
|
545
|
+
paragraphs(
|
|
546
|
+
'Why Autoresearch creates a new buying moment',
|
|
547
|
+
[
|
|
548
|
+
'Autoresearch-style systems run experiments, inspect results, and keep the variants that look better. That makes them powerful, but it also creates a trust gap for engineering teams.',
|
|
549
|
+
'If the loop can edit the benchmark, skip a holdout, hide a failed run, or promote without proof, the buyer needs enforcement before autonomy expands.',
|
|
550
|
+
],
|
|
551
|
+
),
|
|
552
|
+
bullets(
|
|
553
|
+
'Where ThumbGate fits',
|
|
554
|
+
[
|
|
555
|
+
'Block promotion when required primary and holdout checks are missing.',
|
|
556
|
+
'Require commands, changed files, logs, and verification evidence before a claimed improvement lands.',
|
|
557
|
+
'Capture thumbs-down reviews when an experiment cheats the metric, then promote the pattern into a prevention rule.',
|
|
558
|
+
'Use ContextFS packs and Thompson Sampling so recurring research failures get stricter over time.',
|
|
559
|
+
],
|
|
560
|
+
),
|
|
561
|
+
paragraphs(
|
|
562
|
+
'Starter harnesses that make the value visible',
|
|
563
|
+
[
|
|
564
|
+
'The first pack should wrap checks buyers already understand: npm test, lint, Playwright duration, bundle size, and CI status. Each one becomes a gate the buyer can see firing.',
|
|
565
|
+
],
|
|
566
|
+
),
|
|
567
|
+
],
|
|
568
|
+
faq: [
|
|
569
|
+
answer(
|
|
570
|
+
'Why do Autoresearch-style agents need gates?',
|
|
571
|
+
'A self-improving loop can optimize the wrong signal, skip holdout tests, or promote a cherry-picked run. ThumbGate blocks known-bad promotion patterns before the agent accepts the variant.',
|
|
572
|
+
),
|
|
573
|
+
answer(
|
|
574
|
+
'What does ThumbGate add to an Autoresearch loop?',
|
|
575
|
+
'ThumbGate adds structured thumbs-up/down feedback, prevention rules, Thompson Sampling, ContextFS proof packs, and pre-action gates for risky experiment and promotion steps.',
|
|
576
|
+
),
|
|
577
|
+
],
|
|
578
|
+
relatedPaths: ['/guides/pre-action-gates', '/guides/codex-cli-guardrails'],
|
|
579
|
+
}),
|
|
487
580
|
{
|
|
488
581
|
query: 'claude desktop extension plugin thumbgate',
|
|
489
582
|
path: '/guides/claude-desktop',
|
|
@@ -651,6 +744,7 @@ function classifyIntent(query) {
|
|
|
651
744
|
if (!normalized) return 'informational';
|
|
652
745
|
if (/\b(vs|versus|alternative|compare|comparison|better than)\b/.test(normalized)) return 'comparison';
|
|
653
746
|
if (/\b(price|pricing|buy|checkout|purchase|cost)\b/.test(normalized)) return 'transactional';
|
|
747
|
+
if (/\b(autoresearch|self-improving|benchmark|reward hacking|agent safety)\b/.test(normalized)) return 'commercial';
|
|
654
748
|
if (/\b(claude code|cursor|codex|gemini|amp|opencode|integration|plugin|setup|install)\b/.test(normalized)) {
|
|
655
749
|
return 'commercial';
|
|
656
750
|
}
|
|
@@ -665,6 +759,7 @@ function inferPillar(query) {
|
|
|
665
759
|
const normalized = normalizeText(query).toLowerCase();
|
|
666
760
|
if (/\b(speclock|mem0|alternative|vs|compare|comparison)\b/.test(normalized)) return 'comparison';
|
|
667
761
|
if (/\b(thumbs up|thumbs down|feedback|reinforce|mistake)\b/.test(normalized)) return 'feedback-loop';
|
|
762
|
+
if (/\b(autoresearch|self-improving|benchmark|reward hacking)\b/.test(normalized)) return 'pre-action-gates';
|
|
668
763
|
if (/\b(pre-action gates|guardrails|block|prevent repeated mistakes|repeating mistakes)\b/.test(normalized)) return 'pre-action-gates';
|
|
669
764
|
if (/\b(claude code|cursor|codex|gemini|amp|opencode|integration|plugin)\b/.test(normalized)) return 'agent-workflows';
|
|
670
765
|
return 'ai-agent-reliability';
|
|
@@ -676,6 +771,7 @@ function inferPersona(query) {
|
|
|
676
771
|
if (normalized.includes('cursor')) return 'cursor-builder';
|
|
677
772
|
if (normalized.includes('codex')) return 'codex-builder';
|
|
678
773
|
if (normalized.includes('gemini')) return 'gemini-builder';
|
|
774
|
+
if (normalized.includes('autoresearch') || normalized.includes('self-improving')) return 'ai-research-engineer';
|
|
679
775
|
if (/\b(vs|alternative|compare)\b/.test(normalized)) return 'tool-evaluator';
|
|
680
776
|
if (/\b(guardrails|pre-action gates)\b/.test(normalized)) return 'engineering-lead';
|
|
681
777
|
return 'ai-engineer';
|
|
@@ -841,8 +937,8 @@ function createPageSpec(blueprint, row) {
|
|
|
841
937
|
faq: blueprint.faq,
|
|
842
938
|
relatedPages,
|
|
843
939
|
cta: {
|
|
844
|
-
label: '
|
|
845
|
-
href: `/pro?utm_source=website&utm_medium=seo_page&utm_campaign=${blueprint.path.split('/').filter(Boolean).join('_')}`,
|
|
940
|
+
label: 'Go Pro — $19/mo',
|
|
941
|
+
href: `/checkout/pro?utm_source=website&utm_medium=seo_page&utm_campaign=${blueprint.path.split('/').filter(Boolean).join('_')}&cta_placement=seo_brief&plan_id=pro`,
|
|
846
942
|
},
|
|
847
943
|
proofLinks: [
|
|
848
944
|
{ label: 'Verification evidence', href: PRODUCT.verificationUrl },
|
|
@@ -1043,6 +1139,9 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
|
|
|
1043
1139
|
<meta property="og:type" content="article" />
|
|
1044
1140
|
<meta property="og:url" content="${escapeHtml(canonicalUrl)}" />
|
|
1045
1141
|
<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />
|
|
1142
|
+
<link rel="icon" type="image/svg+xml" href="/thumbgate-icon.png" />
|
|
1143
|
+
<link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg" />
|
|
1144
|
+
<meta property="og:image" content="/og.png" />
|
|
1046
1145
|
<style>
|
|
1047
1146
|
:root {
|
|
1048
1147
|
--bg: #0a0a0b;
|
|
@@ -1084,7 +1183,12 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
|
|
|
1084
1183
|
.brand {
|
|
1085
1184
|
font-weight: 700;
|
|
1086
1185
|
color: var(--text);
|
|
1186
|
+
display: inline-flex;
|
|
1187
|
+
align-items: center;
|
|
1188
|
+
gap: 8px;
|
|
1189
|
+
text-decoration: none;
|
|
1087
1190
|
}
|
|
1191
|
+
.brand .logo-mark { width: 28px; height: 28px; display: block; }
|
|
1088
1192
|
.hero { padding: 72px 0 32px; }
|
|
1089
1193
|
.eyebrow {
|
|
1090
1194
|
display: inline-flex;
|
|
@@ -1163,8 +1267,16 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
|
|
|
1163
1267
|
}
|
|
1164
1268
|
.sidebar-card {
|
|
1165
1269
|
padding: 20px;
|
|
1270
|
+
}
|
|
1271
|
+
/* Only the first sidebar card sticks. Stacking multiple stickies at the
|
|
1272
|
+
same top offset makes them overlap each other on scroll. The related-
|
|
1273
|
+
pages card flows normally below. */
|
|
1274
|
+
.sidebar-card:first-child {
|
|
1166
1275
|
position: sticky;
|
|
1167
1276
|
top: 84px;
|
|
1277
|
+
max-height: calc(100vh - 104px);
|
|
1278
|
+
overflow-y: auto;
|
|
1279
|
+
-webkit-overflow-scrolling: touch;
|
|
1168
1280
|
}
|
|
1169
1281
|
.proof-links {
|
|
1170
1282
|
display: flex;
|
|
@@ -1216,8 +1328,10 @@ function renderSeoPageHtml(page, runtimeConfig = {}) {
|
|
|
1216
1328
|
.grid {
|
|
1217
1329
|
grid-template-columns: 1fr;
|
|
1218
1330
|
}
|
|
1219
|
-
.sidebar-card {
|
|
1331
|
+
.sidebar-card:first-child {
|
|
1220
1332
|
position: static;
|
|
1333
|
+
max-height: none;
|
|
1334
|
+
overflow: visible;
|
|
1221
1335
|
}
|
|
1222
1336
|
}
|
|
1223
1337
|
</style>
|
|
@@ -1229,7 +1343,7 @@ ${renderWebPageJsonLd(page, { appOrigin })}
|
|
|
1229
1343
|
<body>
|
|
1230
1344
|
<div class="topbar">
|
|
1231
1345
|
<div class="container">
|
|
1232
|
-
<a class="brand" href="/"
|
|
1346
|
+
<a class="brand" href="/"><img src="/assets/brand/thumbgate-mark-inline.svg" alt="ThumbGate" class="logo-mark" width="28" height="28"><span class="logo-text">ThumbGate</span></a>
|
|
1233
1347
|
<a href="${escapeHtml(PRODUCT.verificationUrl)}" target="_blank" rel="noopener">Verification evidence</a>
|
|
1234
1348
|
</div>
|
|
1235
1349
|
</div>
|
package/scripts/statusline.sh
CHANGED
|
@@ -151,6 +151,14 @@ esac
|
|
|
151
151
|
osc_link() {
|
|
152
152
|
local url="$1"
|
|
153
153
|
local label="$2"
|
|
154
|
+
# THUMBGATE_STATUSLINE_PLAIN=1 suppresses OSC 8 hyperlinks. Consumers that embed
|
|
155
|
+
# ThumbGate as a non-last row in a multi-line statusline should set this, because
|
|
156
|
+
# some agents (Claude Code) silently drop downstream rows when a preceding row
|
|
157
|
+
# contains OSC 8 sequences.
|
|
158
|
+
if [ "${THUMBGATE_STATUSLINE_PLAIN:-0}" = "1" ]; then
|
|
159
|
+
printf '%s' "$label"
|
|
160
|
+
return 0
|
|
161
|
+
fi
|
|
154
162
|
case "$url" in
|
|
155
163
|
"") printf '%s' "$label" ;;
|
|
156
164
|
*) printf '\033]8;;%s\007%s\033]8;;\007' "$url" "$label" ;;
|
package/scripts/vector-store.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
writeModelFitReport,
|
|
9
9
|
resolveFeedbackDir,
|
|
10
10
|
} = require('./local-model-profile');
|
|
11
|
+
const { runStep } = require('./durability/step');
|
|
11
12
|
|
|
12
13
|
const DEFAULT_FEEDBACK_DIR = resolveFeedbackDir();
|
|
13
14
|
const DEFAULT_LANCE_DIR = path.join(DEFAULT_FEEDBACK_DIR, 'lancedb');
|
|
@@ -125,6 +126,9 @@ async function upsertFeedback(feedbackEvent) {
|
|
|
125
126
|
feedbackEvent.whatWorked || '',
|
|
126
127
|
].filter(Boolean).join('. ');
|
|
127
128
|
|
|
129
|
+
// Embed is pure CPU/model work (transformers.js or stub) — deterministic
|
|
130
|
+
// for a given input, so no retry is needed here. Retry wraps the table
|
|
131
|
+
// write below, which is the actual I/O failure surface.
|
|
128
132
|
const vector = await embed(textForEmbedding);
|
|
129
133
|
|
|
130
134
|
const record = {
|
|
@@ -137,13 +141,23 @@ async function upsertFeedback(feedbackEvent) {
|
|
|
137
141
|
context: feedbackEvent.context || '',
|
|
138
142
|
};
|
|
139
143
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
// Wrap the actual LanceDB write with retry. LanceDB is local-disk in our
|
|
145
|
+
// deployment but can fail on transient fs contention (EBUSY on Windows,
|
|
146
|
+
// lock timeouts on WSL, disk-full edge cases). `feedbackEvent.id` already
|
|
147
|
+
// acts as a stable row identity — re-running this step with the same
|
|
148
|
+
// event produces the same row, so retries are safe.
|
|
149
|
+
await runStep('vector-store.upsertFeedback', {
|
|
150
|
+
retries: 2,
|
|
151
|
+
logger: (msg) => console.warn(msg),
|
|
152
|
+
}, async () => {
|
|
153
|
+
const tableNames = await db.tableNames();
|
|
154
|
+
if (tableNames.includes(TABLE_NAME)) {
|
|
155
|
+
const table = await db.openTable(TABLE_NAME);
|
|
156
|
+
await table.add([record]);
|
|
157
|
+
} else {
|
|
158
|
+
await db.createTable(TABLE_NAME, [record]);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
147
161
|
}
|
|
148
162
|
|
|
149
163
|
async function searchSimilar(queryText, limit = 5) {
|