thumbgate 1.7.0 → 1.9.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.
@@ -15,12 +15,14 @@
15
15
  * address, and a functional unsubscribe method.
16
16
  */
17
17
 
18
+ const dns = require('node:dns').promises;
19
+
18
20
  const PRODUCT_NAME = 'ThumbGate Pro';
19
21
  const DASHBOARD_URL = 'https://thumbgate-production.up.railway.app/dashboard';
20
- const SUPPORT_EMAIL = 'hello@thumbgate.app';
22
+ const DEFAULT_CONTACT_EMAIL = 'igor.ganapolsky@gmail.com';
21
23
  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_REPLY_TO = DEFAULT_CONTACT_EMAIL;
25
+ const DEFAULT_UNSUBSCRIBE_EMAIL = DEFAULT_CONTACT_EMAIL;
24
26
  const DEFAULT_BUSINESS_NAME = 'Max Smith KDP LLC';
25
27
  // CAN-SPAM requires a physical mailing address. Override via THUMBGATE_BUSINESS_ADDRESS.
26
28
  const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94114';
@@ -28,9 +30,23 @@ const DEFAULT_BUSINESS_ADDRESS = '2261 Market Street #4242, San Francisco, CA 94
28
30
  const BRAND_MARK_URL = 'https://thumbgate-production.up.railway.app/thumbgate-icon.png';
29
31
  const RESEND_ENDPOINT = 'https://api.resend.com/emails';
30
32
  const TRIAL_LENGTH_DAYS = 7;
33
+ const SENDER_DNS_CACHE_MS = 10 * 60 * 1000;
34
+ // Bounded to RFC 5321 limits (local-part ≤ 64, domain ≤ 255) to prevent
35
+ // super-linear backtracking on malformed input (Sonar javascript:S5852).
36
+ const ANGLE_EMAIL_RE = /<([^<>@\s]{1,64}@[^<>@\s]{1,255})>/;
37
+ const BARE_EMAIL_RE = /([^\s<>@]{1,64}@[^\s<>@]{1,255})/;
38
+ const DKIM_PUBLIC_KEY_RE = /^p=/i;
39
+ const AMAZON_SES_MX_RE = /feedback-smtp\..*amazonaws\.com\.?$/i;
40
+ const AMAZON_SES_SPF_RE = /include:amazonses\.com/i;
41
+ const TRAILING_EMAIL_DOMAIN_PUNCTUATION = new Set(['>', ')', ',', '.', ';']);
42
+ const senderDnsCache = new Map();
31
43
 
32
44
  function getApiKey() {
33
- return process.env.RESEND_API_KEY || '';
45
+ // Accept both `RESEND_API_KEY` (Railway default, matches provider docs) and
46
+ // the `THUMBGATE_`-prefixed variant that `scripts/billing.js` already honors.
47
+ // Keeping the two readers in sync prevents a silent "skipped: no_api_key"
48
+ // regression if an operator sets only the prefixed name.
49
+ return process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '';
34
50
  }
35
51
 
36
52
  function getFromAddress() {
@@ -41,6 +57,10 @@ function getReplyTo() {
41
57
  return process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || DEFAULT_REPLY_TO;
42
58
  }
43
59
 
60
+ function getSupportEmail() {
61
+ return process.env.THUMBGATE_SUPPORT_EMAIL || getReplyTo();
62
+ }
63
+
44
64
  function getUnsubscribeEmail() {
45
65
  return process.env.THUMBGATE_UNSUBSCRIBE_EMAIL || DEFAULT_UNSUBSCRIBE_EMAIL;
46
66
  }
@@ -57,32 +77,193 @@ function isNonEmptyString(value) {
57
77
  return typeof value === 'string' && value.trim().length > 0;
58
78
  }
59
79
 
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 } = {}) {
80
+ function isTrueEnv(value) {
81
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
82
+ }
83
+
84
+ function extractEmailAddress(value) {
85
+ const text = String(value || '').trim();
86
+ const angleMatch = ANGLE_EMAIL_RE.exec(text);
87
+ if (angleMatch) return angleMatch[1];
88
+ const bareMatch = BARE_EMAIL_RE.exec(text);
89
+ return bareMatch ? bareMatch[1] : '';
90
+ }
91
+
92
+ function getEmailDomain(value) {
93
+ const email = extractEmailAddress(value);
94
+ const at = email.lastIndexOf('@');
95
+ if (at === -1) return '';
96
+ let domain = email.slice(at + 1).trim();
97
+ while (domain && TRAILING_EMAIL_DOMAIN_PUNCTUATION.has(domain.at(-1))) {
98
+ domain = domain.slice(0, -1);
99
+ }
100
+ return domain.toLowerCase();
101
+ }
102
+
103
+ function splitCsv(value) {
104
+ return String(value || '')
105
+ .split(',')
106
+ .map((part) => part.trim().toLowerCase())
107
+ .filter(Boolean);
108
+ }
109
+
110
+ function getVerifiedSenderDomains() {
111
+ return new Set(splitCsv(process.env.THUMBGATE_VERIFIED_SENDER_DOMAINS));
112
+ }
113
+
114
+ function flattenTxt(records) {
115
+ return (records || []).map((chunks) => Array.isArray(chunks) ? chunks.join('') : String(chunks));
116
+ }
117
+
118
+ function getCachedSenderDnsReadiness(cacheKey) {
119
+ if (!cacheKey) return null;
120
+ const cached = senderDnsCache.get(cacheKey);
121
+ return cached && cached.expiresAt > Date.now() ? cached.ready : null;
122
+ }
123
+
124
+ function setCachedSenderDnsReadiness(cacheKey, ready) {
125
+ if (cacheKey) senderDnsCache.set(cacheKey, { ready, expiresAt: Date.now() + SENDER_DNS_CACHE_MS });
126
+ return ready;
127
+ }
128
+
129
+ async function readResendDnsRecords(domain, resolver) {
130
+ try {
131
+ const [dkimRecords, mxRecords, spfRecords] = await Promise.all([
132
+ resolver.resolveTxt(`resend._domainkey.${domain}`),
133
+ resolver.resolveMx(`send.${domain}`),
134
+ resolver.resolveTxt(`send.${domain}`),
135
+ ]);
136
+ return { dkimRecords, mxRecords, spfRecords, errorCode: null };
137
+ } catch (error) {
138
+ return {
139
+ dkimRecords: [],
140
+ mxRecords: [],
141
+ spfRecords: [],
142
+ errorCode: error && error.code ? error.code : 'dns_lookup_failed',
143
+ };
144
+ }
145
+ }
146
+
147
+ function recordsHaveResendDns({ dkimRecords, mxRecords, spfRecords }) {
148
+ const dkim = flattenTxt(dkimRecords);
149
+ const spf = flattenTxt(spfRecords);
150
+ return dkim.some((record) => DKIM_PUBLIC_KEY_RE.exec(record.trim()) !== null) &&
151
+ (mxRecords || []).some((record) => AMAZON_SES_MX_RE.exec(record.exchange || '') !== null) &&
152
+ spf.some((record) => AMAZON_SES_SPF_RE.exec(record) !== null);
153
+ }
154
+
155
+ async function hasResendSenderDns(domain, { dnsResolver } = {}) {
156
+ if (!domain || domain === 'resend.dev') return true;
157
+ if (isTrueEnv(process.env.THUMBGATE_ALLOW_UNVERIFIED_SENDER)) return true;
158
+ if (getVerifiedSenderDomains().has(domain)) return true;
159
+
160
+ const cacheKey = dnsResolver ? null : domain;
161
+ const cached = getCachedSenderDnsReadiness(cacheKey);
162
+ if (cached !== null) return cached;
163
+
164
+ const records = await readResendDnsRecords(domain, dnsResolver || dns);
165
+ return setCachedSenderDnsReadiness(cacheKey, !records.errorCode && recordsHaveResendDns(records));
166
+ }
167
+
168
+ async function resolveSenderAddress(requestedFrom, { dnsResolver } = {}) {
169
+ const from = requestedFrom || getFromAddress();
170
+ const domain = getEmailDomain(from);
171
+ const ready = await hasResendSenderDns(domain, { dnsResolver });
172
+ if (ready) return { from, senderFallback: null };
173
+
174
+ return {
175
+ from: DEFAULT_FROM,
176
+ senderFallback: {
177
+ requestedFrom: from,
178
+ fallbackFrom: DEFAULT_FROM,
179
+ domain,
180
+ reason: 'resend_dns_not_ready',
181
+ },
182
+ };
183
+ }
184
+
185
+ function validateSendEmailInput({ to, subject, html, text }) {
65
186
  if (!isNonEmptyString(to)) throw new Error('sendEmail: `to` is required');
66
187
  if (!isNonEmptyString(subject)) throw new Error('sendEmail: `subject` is required');
67
188
  if (!isNonEmptyString(html) && !isNonEmptyString(text)) {
68
189
  throw new Error('sendEmail: at least one of `html` or `text` is required');
69
190
  }
191
+ }
70
192
 
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
- }
193
+ function warnSenderFallback(senderFallback) {
194
+ if (!senderFallback) return;
195
+ // eslint-disable-next-line no-console
196
+ console.warn(
197
+ `[mailer] Sender domain ${senderFallback.domain || '(unknown)'} is missing Resend DNS; ` +
198
+ `falling back to ${senderFallback.fallbackFrom}`,
199
+ );
200
+ }
77
201
 
202
+ function buildEmailPayload({ to, subject, html, text, replyTo, sender }) {
78
203
  const payload = {
79
- from: from || getFromAddress(),
204
+ from: sender.from,
80
205
  to: Array.isArray(to) ? to : [to],
81
206
  subject,
82
207
  reply_to: replyTo || getReplyTo(),
83
208
  };
84
209
  if (isNonEmptyString(html)) payload.html = html;
85
210
  if (isNonEmptyString(text)) payload.text = text;
211
+ return payload;
212
+ }
213
+
214
+ function parseJsonOrNull(bodyText) {
215
+ if (!bodyText) return null;
216
+ try {
217
+ return JSON.parse(bodyText);
218
+ } catch (error) {
219
+ if (!(error instanceof SyntaxError)) throw error;
220
+ return null;
221
+ }
222
+ }
223
+
224
+ async function postResendEmail({ fetcher, apiKey, payload }) {
225
+ const res = await fetcher(RESEND_ENDPOINT, {
226
+ method: 'POST',
227
+ headers: {
228
+ 'Content-Type': 'application/json',
229
+ Authorization: `Bearer ${apiKey}`,
230
+ },
231
+ body: JSON.stringify(payload),
232
+ });
233
+ const bodyText = typeof res.text === 'function' ? await res.text() : '';
234
+ return { res, bodyText, bodyJson: parseJsonOrNull(bodyText) };
235
+ }
236
+
237
+ function formatSendFailure(error) {
238
+ return error && error.message ? error.message : String(error);
239
+ }
240
+
241
+ function buildSendSuccess({ bodyJson, status, senderFallback }) {
242
+ return {
243
+ sent: true,
244
+ id: bodyJson?.id || null,
245
+ status,
246
+ ...(senderFallback ? { senderFallback } : {}),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
252
+ * missing. Never throws on network errors; returns a structured result instead.
253
+ */
254
+ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dnsResolver } = {}) {
255
+ validateSendEmailInput({ to, subject, html, text });
256
+
257
+ const apiKey = getApiKey();
258
+ if (!apiKey) {
259
+ // eslint-disable-next-line no-console
260
+ console.warn('[mailer] RESEND_API_KEY not set — skipping send to', to);
261
+ return { sent: false, reason: 'no_api_key' };
262
+ }
263
+
264
+ const sender = await resolveSenderAddress(from || getFromAddress(), { dnsResolver });
265
+ warnSenderFallback(sender.senderFallback);
266
+ const payload = buildEmailPayload({ to, subject, html, text, replyTo, sender });
86
267
 
87
268
  const fetcher = fetchImpl || globalThis.fetch;
88
269
  if (typeof fetcher !== 'function') {
@@ -92,32 +273,17 @@ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl } =
92
273
  }
93
274
 
94
275
  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
-
276
+ const { res, bodyText, bodyJson } = await postResendEmail({ fetcher, apiKey, payload });
110
277
  if (!res.ok) {
111
278
  // eslint-disable-next-line no-console
112
279
  console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
113
280
  return { sent: false, reason: 'api_error', status: res.status, body: bodyJson || bodyText };
114
281
  }
115
-
116
- return { sent: true, id: bodyJson && bodyJson.id ? bodyJson.id : null, status: res.status };
282
+ return buildSendSuccess({ bodyJson, status: res.status, senderFallback: sender.senderFallback });
117
283
  } catch (err) {
118
284
  // 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) };
285
+ console.warn('[mailer] send failed:', formatSendFailure(err));
286
+ return { sent: false, reason: 'exception', error: formatSendFailure(err) };
121
287
  }
122
288
  }
123
289
 
@@ -173,6 +339,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
173
339
  const exampleFeedback =
174
340
  'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
175
341
  const proofUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
342
+ const supportEmail = getSupportEmail();
176
343
  const unsubscribeEmail = getUnsubscribeEmail();
177
344
  const businessName = getBusinessName();
178
345
  const businessAddress = getBusinessAddress();
@@ -205,7 +372,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
205
372
  '',
206
373
  postscript,
207
374
  '',
208
- `Questions? Just reply to this email or write ${SUPPORT_EMAIL}.`,
375
+ `Questions? Just reply to this email or write ${supportEmail}.`,
209
376
  '',
210
377
  '— Igor, founder of ThumbGate',
211
378
  '',
@@ -222,6 +389,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
222
389
  const safeDescription = escapeHtml(description);
223
390
  const safeExample = escapeHtml(exampleFeedback);
224
391
  const safePostscript = escapeHtml(postscript);
392
+ const safeSupportEmail = escapeHtml(supportEmail);
225
393
  const safeBusinessName = escapeHtml(businessName);
226
394
  const safeBusinessAddress = escapeHtml(businessAddress);
227
395
  const safeUnsubscribeEmail = escapeHtml(unsubscribeEmail);
@@ -281,7 +449,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
281
449
  <p style="margin:0 0 4px;font-size:14px;line-height:1.6;color:#17212b;">— Igor, founder of ThumbGate</p>
282
450
  <p style="margin:0;font-size:13px;line-height:1.55;color:#526273;">
283
451
  Questions? Just reply to this email or write
284
- <a href="mailto:${SUPPORT_EMAIL}" style="color:#087a91;">${SUPPORT_EMAIL}</a>.
452
+ <a href="mailto:${safeSupportEmail}" style="color:#087a91;">${safeSupportEmail}</a>.
285
453
  </p>
286
454
  </td>
287
455
  </tr>
@@ -317,7 +485,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
317
485
  * Never throws on send failures (beyond input validation); the Stripe webhook
318
486
  * must keep working even if email breaks.
319
487
  */
320
- async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl } = {}) {
488
+ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl, dnsResolver } = {}) {
321
489
  if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
322
490
  if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
323
491
 
@@ -327,17 +495,19 @@ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName,
327
495
  ? `${name}, your ThumbGate Pro key is inside`
328
496
  : 'Your ThumbGate Pro key is inside';
329
497
 
330
- return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl });
498
+ return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl, dnsResolver });
331
499
  }
332
500
 
333
501
  module.exports = {
334
502
  sendEmail,
335
503
  sendTrialWelcomeEmail,
336
504
  renderTrialWelcomeBodies,
505
+ _resolveSenderAddress: resolveSenderAddress,
506
+ _hasResendSenderDns: hasResendSenderDns,
337
507
  _constants: {
338
508
  PRODUCT_NAME,
339
509
  DASHBOARD_URL,
340
- SUPPORT_EMAIL,
510
+ DEFAULT_CONTACT_EMAIL,
341
511
  DEFAULT_FROM,
342
512
  DEFAULT_REPLY_TO,
343
513
  DEFAULT_UNSUBSCRIBE_EMAIL,
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_EVIDENCE_TYPES = ['screenshots', 'pdf_pages', 'proof_artifacts'];
4
+ const DEFAULT_DIMS = [1024, 512, 256, 128, 64];
5
+
6
+ function clampInteger(value, { min, max, fallback }) {
7
+ const parsed = Number(value);
8
+ if (!Number.isFinite(parsed)) return fallback;
9
+ return Math.max(min, Math.min(max, Math.floor(parsed)));
10
+ }
11
+
12
+ function normalizeEvidenceTypes(value) {
13
+ if (!Array.isArray(value)) return DEFAULT_EVIDENCE_TYPES;
14
+ const normalized = value
15
+ .map((item) => String(item || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_'))
16
+ .filter(Boolean);
17
+ return normalized.length > 0 ? [...new Set(normalized)] : DEFAULT_EVIDENCE_TYPES;
18
+ }
19
+
20
+ function dimensionPlan({ corpusItems, maxEmbeddingDim }) {
21
+ const dims = DEFAULT_DIMS.filter((dim) => dim <= maxEmbeddingDim);
22
+ const selected = dims.length > 0 ? dims : [maxEmbeddingDim];
23
+ return selected.map((dim) => ({
24
+ dim,
25
+ estimatedFloat32Mb: Number(((corpusItems * dim * 4) / (1024 * 1024)).toFixed(2)),
26
+ useWhen: dim >= 1024
27
+ ? 'default quality pass for launch-critical retrieval'
28
+ : 'cost-down pass when storage or latency dominates',
29
+ }));
30
+ }
31
+
32
+ function buildMultimodalRetrievalPlan(args = {}) {
33
+ const evidenceTypes = normalizeEvidenceTypes(args.evidenceTypes);
34
+ const corpusItems = clampInteger(args.corpusItems, {
35
+ min: 100,
36
+ max: 10000000,
37
+ fallback: 5000,
38
+ });
39
+ const maxEmbeddingDim = clampInteger(args.maxEmbeddingDim, {
40
+ min: 64,
41
+ max: 2048,
42
+ fallback: 1024,
43
+ });
44
+ const latencyBudgetMs = clampInteger(args.latencyBudgetMs, {
45
+ min: 50,
46
+ max: 30000,
47
+ fallback: 750,
48
+ });
49
+ const useReranker = args.useReranker !== false;
50
+ const goal = String(args.goal || 'retrieve visual proof for agent-governance decisions').trim();
51
+ const dims = dimensionPlan({ corpusItems, maxEmbeddingDim });
52
+ const defaultDim = dims.some((entry) => entry.dim === 1024) ? 1024 : dims[0].dim;
53
+
54
+ return {
55
+ planVersion: '2026-04-20',
56
+ sourcePattern: 'multimodal Sentence Transformers visual document retrieval',
57
+ goal,
58
+ evidenceTypes,
59
+ architecture: {
60
+ stage1: 'Index screenshots, PDF pages, dashboard captures, and proof artifacts with a multimodal embedding model.',
61
+ stage2: useReranker
62
+ ? 'Rerank the top candidates with a multimodal cross-encoder before using evidence in a gate, PR, or sales proof claim.'
63
+ : 'Skip reranking for low-latency agent recall; require stronger holdout evaluation before shipping.',
64
+ fallback: 'Keep text-only search as a fallback for code, logs, markdown, and plain policy docs.',
65
+ },
66
+ trainingData: {
67
+ pilotSchema: ['query', 'image', 'negative_0'],
68
+ hardNegativeStrategy: 'Pair each proof query with visually similar but wrong screenshots or PDF pages.',
69
+ minimumPilot: 'Start with 300 labeled evaluation queries and at least one hard negative per query before finetuning.',
70
+ },
71
+ evaluation: {
72
+ baseline: 'Measure current text-only retrieval before any model changes.',
73
+ primaryMetric: 'NDCG@10',
74
+ secondaryMetrics: ['Recall@5', 'MAP', 'false_positive_gate_rate'],
75
+ holdoutSets: [
76
+ 'agent failure screenshots',
77
+ 'dashboard proof captures',
78
+ 'visual docs that contain tables or charts',
79
+ ],
80
+ },
81
+ deployment: {
82
+ latencyBudgetMs,
83
+ defaultEmbeddingDim: defaultDim,
84
+ matryoshkaDimensions: dims,
85
+ compressionPath: 'Use Matryoshka truncation first, then quantization only after holdout quality is stable.',
86
+ },
87
+ thumbgateUseCases: [
88
+ 'Find the exact screenshot or proof artifact behind a completion claim.',
89
+ 'Retrieve visual evidence before approving a workflow-hardening sprint.',
90
+ 'Rank dashboard captures and PDF runbook pages for GEO/SEO evidence pages.',
91
+ 'Attach visual hard negatives to Autoresearch loops so agents cannot reward-hack by deleting hard cases.',
92
+ ],
93
+ guardrails: [
94
+ 'Never promote visual retrieval results into claims without a linked artifact URL or local path.',
95
+ 'Keep the multimodal index read-only for agent recall; gate training and index rebuilds behind explicit workflow checks.',
96
+ 'Evaluate retrieval on holdout screenshots/PDF pages before replacing text-only recall.',
97
+ ],
98
+ nextActions: [
99
+ 'Create a small visual proof corpus from existing public dashboard screenshots and proof artifacts.',
100
+ 'Log query -> correct artifact -> hard negative triples during workflow sprint reviews.',
101
+ 'Use Autoresearch to optimize NDCG@10 and latency only after the baseline corpus exists.',
102
+ ],
103
+ };
104
+ }
105
+
106
+ module.exports = {
107
+ buildMultimodalRetrievalPlan,
108
+ dimensionPlan,
109
+ normalizeEvidenceTypes,
110
+ };
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const { getRuntimeDir, resolveProjectDir } = require('./feedback-paths');
9
+
10
+ const FIXED_GH_BINARIES = [
11
+ '/usr/bin/gh',
12
+ '/usr/local/bin/gh',
13
+ '/opt/homebrew/bin/gh',
14
+ ];
15
+ const FIXED_GIT_BINARIES = [
16
+ '/usr/bin/git',
17
+ '/usr/local/bin/git',
18
+ '/opt/homebrew/bin/git',
19
+ ];
20
+
21
+ const CONTEXT_CACHE_MAX_AGE_MS = 120000;
22
+
23
+ function contextCachePath(options = {}) {
24
+ return path.join(getRuntimeDir(options), 'statusline-context.json');
25
+ }
26
+
27
+ function readContextCache(options = {}) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(contextCachePath(options), 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function writeContextCache(payload, options = {}) {
36
+ const targetPath = contextCachePath(options);
37
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
38
+ fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2) + '\n');
39
+ return targetPath;
40
+ }
41
+
42
+ function isFreshCache(cache, now = Date.now()) {
43
+ if (!cache || !cache.updatedAt) {
44
+ return false;
45
+ }
46
+
47
+ const updatedAt = Date.parse(cache.updatedAt);
48
+ if (!Number.isFinite(updatedAt)) {
49
+ return false;
50
+ }
51
+
52
+ return (now - updatedAt) <= CONTEXT_CACHE_MAX_AGE_MS;
53
+ }
54
+
55
+ function resolveGhBinary(accessSync = fs.accessSync) {
56
+ for (const candidate of FIXED_GH_BINARIES) {
57
+ try {
58
+ accessSync(candidate, fs.constants.X_OK);
59
+ return candidate;
60
+ } catch {
61
+ continue;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function resolveGitBinary(options = {}) {
68
+ const env = options.env || process.env;
69
+ const configuredBinary = String(env.THUMBGATE_GIT_BIN || '').trim();
70
+ const candidates = configuredBinary
71
+ ? [configuredBinary, ...FIXED_GIT_BINARIES]
72
+ : FIXED_GIT_BINARIES;
73
+
74
+ for (const candidate of candidates) {
75
+ try {
76
+ fs.accessSync(candidate, fs.constants.X_OK);
77
+ return candidate;
78
+ } catch {
79
+ continue;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function runCommand(command, args, options = {}) {
87
+ const result = spawnSync(command, args, {
88
+ cwd: options.cwd,
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ timeout: options.timeoutMs || 400,
92
+ });
93
+
94
+ if (result.status !== 0) {
95
+ return '';
96
+ }
97
+
98
+ return String(result.stdout || '').trim();
99
+ }
100
+
101
+ function getBranchName(projectDir) {
102
+ if (!projectDir) {
103
+ return '';
104
+ }
105
+
106
+ const gitBinary = resolveGitBinary();
107
+ if (!gitBinary) {
108
+ return '';
109
+ }
110
+
111
+ return runCommand(gitBinary, ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectDir, timeoutMs: 300 });
112
+ }
113
+
114
+ function inferWorkItemLabel(branchName = '') {
115
+ const normalized = String(branchName || '').trim();
116
+ if (!normalized) {
117
+ return '';
118
+ }
119
+
120
+ const explicitMatch = /(?:^|[/-])(AB#\d+)(?:$|[/-])/i.exec(normalized);
121
+ if (explicitMatch) {
122
+ return explicitMatch[1].toUpperCase();
123
+ }
124
+
125
+ const numericMatch = /(?:^|[/-])(\d{4,})(?:$|[/-])/.exec(normalized);
126
+ if (!numericMatch) {
127
+ return '';
128
+ }
129
+
130
+ return `AB#${numericMatch[1]}`;
131
+ }
132
+
133
+ function getPrNumber(projectDir, options = {}) {
134
+ if (!projectDir) {
135
+ return '';
136
+ }
137
+
138
+ const ghBinary = options.ghBinary || resolveGhBinary(options.accessSync);
139
+ if (!ghBinary) {
140
+ return '';
141
+ }
142
+
143
+ const prNumber = runCommand(
144
+ ghBinary,
145
+ ['pr', 'view', '--json', 'number', '--jq', '.number'],
146
+ { cwd: projectDir, timeoutMs: options.timeoutMs || 1000 }
147
+ );
148
+ return /^\d+$/.test(prNumber) ? prNumber : '';
149
+ }
150
+
151
+ function getStatuslineContext(options = {}) {
152
+ const env = options.env || process.env;
153
+ if (env._TEST_THUMBGATE_STATUSLINE_CONTEXT_JSON) {
154
+ return JSON.parse(env._TEST_THUMBGATE_STATUSLINE_CONTEXT_JSON);
155
+ }
156
+
157
+ const projectDir = resolveProjectDir({ env, cwd: options.cwd || process.cwd() });
158
+ const cache = readContextCache({ env, home: options.homeDir });
159
+ const branchName = (env.THUMBGATE_STATUSLINE_BRANCH || '').trim() || getBranchName(projectDir);
160
+ const workItemLabel = (env.THUMBGATE_STATUSLINE_WORK_ITEM || '').trim() || inferWorkItemLabel(branchName);
161
+
162
+ let prNumber = (env.THUMBGATE_STATUSLINE_PR_NUMBER || '').trim();
163
+ if (!prNumber && cache && cache.projectDir === projectDir && cache.branchName === branchName && isFreshCache(cache)) {
164
+ prNumber = String(cache.prNumber || '').trim();
165
+ }
166
+ if (!prNumber) {
167
+ prNumber = getPrNumber(projectDir, options);
168
+ }
169
+
170
+ const payload = {
171
+ branchName,
172
+ workItemLabel,
173
+ prNumber,
174
+ prLabel: prNumber ? `PR #${prNumber}` : '',
175
+ projectDir,
176
+ updatedAt: new Date().toISOString(),
177
+ };
178
+ writeContextCache(payload, { env, home: options.homeDir });
179
+ return payload;
180
+ }
181
+
182
+ function isCliInvocation(argv = process.argv) {
183
+ const invokedPath = argv[1];
184
+ return invokedPath ? path.resolve(invokedPath) === __filename : false;
185
+ }
186
+
187
+ if (isCliInvocation()) {
188
+ try {
189
+ process.stdout.write(JSON.stringify(getStatuslineContext()));
190
+ } catch {
191
+ process.exit(0);
192
+ }
193
+ }
194
+
195
+ module.exports = {
196
+ CONTEXT_CACHE_MAX_AGE_MS,
197
+ contextCachePath,
198
+ getBranchName,
199
+ getPrNumber,
200
+ getStatuslineContext,
201
+ inferWorkItemLabel,
202
+ isFreshCache,
203
+ readContextCache,
204
+ resolveGhBinary,
205
+ resolveGitBinary,
206
+ writeContextCache,
207
+ };