thumbgate 1.18.0 → 1.19.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/README.md +26 -4
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/config/model-candidates.json +31 -0
- package/package.json +25 -6
- package/public/compare.html +6 -0
- package/public/federal.html +375 -0
- package/public/guide.html +2 -2
- package/public/index.html +34 -5
- package/public/learn.html +28 -0
- package/public/numbers.html +2 -2
- package/public/pro.html +4 -4
- package/scripts/activation-tracker.js +127 -0
- package/scripts/feedback-loop.js +14 -1
- package/scripts/memory-scope-readiness.js +315 -0
- package/scripts/plausible-server-events.js +162 -0
- package/scripts/seo-gsd.js +75 -2
- package/scripts/statusline-links.js +2 -0
- package/scripts/telemetry-analytics.js +1 -0
- package/src/api/server.js +535 -11
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Activation tracker — fires `activation_first_rule_promoted` exactly once
|
|
5
|
+
* per install, the first time a prevention rule auto-promotes from feedback.
|
|
6
|
+
*
|
|
7
|
+
* Why: v1.17.0 opened the free tier (5 active rules, unlimited captures).
|
|
8
|
+
* The metric that decides whether that worked is the % of `npx thumbgate init`
|
|
9
|
+
* runs that produce a first auto-promoted prevention rule within 24h. Without
|
|
10
|
+
* this telemetry, every funnel decision is guessing. This is improvement #1
|
|
11
|
+
* from the 2026-05-13 revenue-ROI critique.
|
|
12
|
+
*
|
|
13
|
+
* Payload (anonymous):
|
|
14
|
+
* - eventType: 'activation_first_rule_promoted'
|
|
15
|
+
* - installId: stable random UUID (from cli-telemetry.getInstallId)
|
|
16
|
+
* - daysToFirstRule: days between INSTALL_ID_PATH creation and now
|
|
17
|
+
* - visitorType: ci | owner | real_user (from cli-telemetry.classifyInstall)
|
|
18
|
+
*
|
|
19
|
+
* No personal data, no rule content, no feedback text. Just the activation
|
|
20
|
+
* signal. Respects THUMBGATE_NO_TELEMETRY=1 / DO_NOT_TRACK=1 opt-out via
|
|
21
|
+
* the underlying trackEvent helper.
|
|
22
|
+
*
|
|
23
|
+
* Idempotency: writes a marker file. After the first firing the function
|
|
24
|
+
* is a no-op for the lifetime of that install.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const { trackEvent, getInstallId, classifyInstall, INSTALL_ID_PATH } = require('./cli-telemetry');
|
|
31
|
+
|
|
32
|
+
const MARKER_DIR = path.join(path.dirname(INSTALL_ID_PATH), 'activation');
|
|
33
|
+
const MARKER_PATH = path.join(MARKER_DIR, 'first-rule-promoted.json');
|
|
34
|
+
|
|
35
|
+
function readMarker() {
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(MARKER_PATH)) return null;
|
|
38
|
+
return JSON.parse(fs.readFileSync(MARKER_PATH, 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeMarker(record) {
|
|
45
|
+
try {
|
|
46
|
+
if (!fs.existsSync(MARKER_DIR)) fs.mkdirSync(MARKER_DIR, { recursive: true });
|
|
47
|
+
fs.writeFileSync(MARKER_PATH, JSON.stringify(record, null, 2));
|
|
48
|
+
} catch {
|
|
49
|
+
// non-fatal: worst case we double-fire on a future run; the telemetry
|
|
50
|
+
// backend can dedup by installId. Better to be silent than to throw.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function computeDaysSinceInstall() {
|
|
55
|
+
try {
|
|
56
|
+
if (!fs.existsSync(INSTALL_ID_PATH)) return null;
|
|
57
|
+
const installed = fs.statSync(INSTALL_ID_PATH).mtimeMs;
|
|
58
|
+
if (!Number.isFinite(installed) || installed <= 0) return null;
|
|
59
|
+
const diffMs = Date.now() - installed;
|
|
60
|
+
if (diffMs < 0) return 0;
|
|
61
|
+
// 1 decimal of precision; the metric uses 24h buckets but a finer-grained
|
|
62
|
+
// number lets analytics chart same-day vs. 1d / 2d / week-of activation.
|
|
63
|
+
return Math.round((diffMs / 86_400_000) * 10) / 10;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fire the activation event if this is the first rule promotion for this
|
|
71
|
+
* install. Returns true if an event was actually emitted, false if skipped
|
|
72
|
+
* (already fired before, or environment opted out of telemetry).
|
|
73
|
+
*
|
|
74
|
+
* Always synchronous and never throws — safe to call from any side-effect
|
|
75
|
+
* site without try/catch wrapping. The underlying telemetry call is itself
|
|
76
|
+
* fire-and-forget over HTTPS with a 3s timeout.
|
|
77
|
+
*/
|
|
78
|
+
function recordFirstRulePromotion(metadata = {}) {
|
|
79
|
+
if (readMarker()) return false; // already fired
|
|
80
|
+
|
|
81
|
+
if (process.env.THUMBGATE_NO_TELEMETRY === '1' || process.env.DO_NOT_TRACK === '1') {
|
|
82
|
+
// Still write the marker so a later run with telemetry re-enabled does not
|
|
83
|
+
// fire stale activation data weeks after the actual first promotion.
|
|
84
|
+
writeMarker({
|
|
85
|
+
installId: getInstallId(),
|
|
86
|
+
promotedAt: new Date().toISOString(),
|
|
87
|
+
telemetryOptOut: true,
|
|
88
|
+
});
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const installId = getInstallId();
|
|
93
|
+
const daysToFirstRule = computeDaysSinceInstall();
|
|
94
|
+
const visitorType = classifyInstall();
|
|
95
|
+
|
|
96
|
+
writeMarker({
|
|
97
|
+
installId,
|
|
98
|
+
promotedAt: new Date().toISOString(),
|
|
99
|
+
daysToFirstRule,
|
|
100
|
+
visitorType,
|
|
101
|
+
...metadata,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
trackEvent('activation_first_rule_promoted', {
|
|
105
|
+
daysToFirstRule,
|
|
106
|
+
visitorType,
|
|
107
|
+
...metadata,
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resetForTesting() {
|
|
113
|
+
// Test-only helper. Removes the marker so a subsequent recordFirstRulePromotion
|
|
114
|
+
// call re-fires. Never used in production code paths.
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(MARKER_PATH)) fs.unlinkSync(MARKER_PATH);
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
recordFirstRulePromotion,
|
|
122
|
+
computeDaysSinceInstall,
|
|
123
|
+
readMarker,
|
|
124
|
+
writeMarker,
|
|
125
|
+
MARKER_PATH,
|
|
126
|
+
resetForTesting,
|
|
127
|
+
};
|
package/scripts/feedback-loop.js
CHANGED
|
@@ -1398,7 +1398,20 @@ function captureFeedback(params) {
|
|
|
1398
1398
|
if (feedbackEvent.signal === 'negative') {
|
|
1399
1399
|
try {
|
|
1400
1400
|
const autoPromote = require('./auto-promote-gates');
|
|
1401
|
-
autoPromote.promote(FEEDBACK_LOG_PATH);
|
|
1401
|
+
const promoteResult = autoPromote.promote(FEEDBACK_LOG_PATH);
|
|
1402
|
+
// First-rule activation telemetry: anonymous ping the first time
|
|
1403
|
+
// a prevention rule auto-promotes for this install. Idempotent —
|
|
1404
|
+
// see scripts/activation-tracker.js. Critical for activation funnel
|
|
1405
|
+
// analytics; no rule content, just install_id + days_to_first_rule.
|
|
1406
|
+
if (promoteResult && Array.isArray(promoteResult.promotions) && promoteResult.promotions.length > 0) {
|
|
1407
|
+
try {
|
|
1408
|
+
const { recordFirstRulePromotion } = require('./activation-tracker');
|
|
1409
|
+
recordFirstRulePromotion({
|
|
1410
|
+
promotionCount: promoteResult.promotions.length,
|
|
1411
|
+
totalGates: promoteResult.totalGates,
|
|
1412
|
+
});
|
|
1413
|
+
} catch { /* activation telemetry is non-critical */ }
|
|
1414
|
+
}
|
|
1402
1415
|
} catch { /* Gate promotion is non-critical */ }
|
|
1403
1416
|
}
|
|
1404
1417
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const REQUIRED_SCOPE_FIELDS = ['entityId', 'projectId', 'processId', 'sessionId'];
|
|
5
|
+
|
|
6
|
+
const FIELD_ALIASES = {
|
|
7
|
+
entityId: [
|
|
8
|
+
'entityId',
|
|
9
|
+
'userId',
|
|
10
|
+
'user_id',
|
|
11
|
+
'accountId',
|
|
12
|
+
'tenantId',
|
|
13
|
+
'actorId',
|
|
14
|
+
'scope.entityId',
|
|
15
|
+
'scope.userId',
|
|
16
|
+
'scope.user_id',
|
|
17
|
+
'metadata.entityId',
|
|
18
|
+
'metadata.userId',
|
|
19
|
+
'metadata.user_id',
|
|
20
|
+
'metadata.tenantId',
|
|
21
|
+
'richContext.entityId',
|
|
22
|
+
'richContext.userId',
|
|
23
|
+
'context.entityId',
|
|
24
|
+
'context.userId',
|
|
25
|
+
],
|
|
26
|
+
projectId: [
|
|
27
|
+
'projectId',
|
|
28
|
+
'project_id',
|
|
29
|
+
'project',
|
|
30
|
+
'repoId',
|
|
31
|
+
'workspaceId',
|
|
32
|
+
'scope.projectId',
|
|
33
|
+
'scope.project_id',
|
|
34
|
+
'scope.project',
|
|
35
|
+
'metadata.projectId',
|
|
36
|
+
'metadata.project_id',
|
|
37
|
+
'metadata.project',
|
|
38
|
+
'metadata.repoId',
|
|
39
|
+
'metadata.workspaceId',
|
|
40
|
+
'richContext.projectId',
|
|
41
|
+
'richContext.project',
|
|
42
|
+
'context.projectId',
|
|
43
|
+
'context.project',
|
|
44
|
+
],
|
|
45
|
+
processId: [
|
|
46
|
+
'processId',
|
|
47
|
+
'process_id',
|
|
48
|
+
'agentId',
|
|
49
|
+
'agent_id',
|
|
50
|
+
'role',
|
|
51
|
+
'scope.processId',
|
|
52
|
+
'scope.process_id',
|
|
53
|
+
'scope.agentId',
|
|
54
|
+
'scope.agent_id',
|
|
55
|
+
'metadata.processId',
|
|
56
|
+
'metadata.process_id',
|
|
57
|
+
'metadata.agentId',
|
|
58
|
+
'metadata.agent_id',
|
|
59
|
+
'metadata.role',
|
|
60
|
+
'richContext.processId',
|
|
61
|
+
'richContext.agentId',
|
|
62
|
+
'context.processId',
|
|
63
|
+
'context.agentId',
|
|
64
|
+
],
|
|
65
|
+
sessionId: [
|
|
66
|
+
'sessionId',
|
|
67
|
+
'session_id',
|
|
68
|
+
'conversationId',
|
|
69
|
+
'threadId',
|
|
70
|
+
'runId',
|
|
71
|
+
'scope.sessionId',
|
|
72
|
+
'scope.session_id',
|
|
73
|
+
'metadata.sessionId',
|
|
74
|
+
'metadata.session_id',
|
|
75
|
+
'metadata.conversationId',
|
|
76
|
+
'metadata.threadId',
|
|
77
|
+
'metadata.runId',
|
|
78
|
+
'richContext.sessionId',
|
|
79
|
+
'richContext.conversationId',
|
|
80
|
+
'context.sessionId',
|
|
81
|
+
'context.conversationId',
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function readPath(value, dottedPath) {
|
|
86
|
+
const segments = dottedPath.split('.');
|
|
87
|
+
let current = value;
|
|
88
|
+
for (const segment of segments) {
|
|
89
|
+
if (!current || typeof current !== 'object' || !(segment in current)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
current = current[segment];
|
|
93
|
+
}
|
|
94
|
+
return current;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeId(value) {
|
|
98
|
+
if (value === null || value === undefined) return null;
|
|
99
|
+
const text = String(value).trim();
|
|
100
|
+
return text ? text : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeScope(input = {}) {
|
|
104
|
+
const scope = {};
|
|
105
|
+
for (const field of REQUIRED_SCOPE_FIELDS) {
|
|
106
|
+
let resolved = null;
|
|
107
|
+
for (const alias of FIELD_ALIASES[field]) {
|
|
108
|
+
resolved = normalizeId(readPath(input, alias));
|
|
109
|
+
if (resolved) break;
|
|
110
|
+
}
|
|
111
|
+
scope[field] = resolved;
|
|
112
|
+
}
|
|
113
|
+
return scope;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function missingScopeFields(scope) {
|
|
117
|
+
const normalized = normalizeScope(scope);
|
|
118
|
+
return REQUIRED_SCOPE_FIELDS.filter((field) => !normalized[field]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function memoryScopeKey(scope) {
|
|
122
|
+
const normalized = normalizeScope(scope);
|
|
123
|
+
if (missingScopeFields(normalized).length > 0) return null;
|
|
124
|
+
return REQUIRED_SCOPE_FIELDS.map((field) => `${field}:${normalized[field]}`).join('|');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isSharedMemory(record = {}) {
|
|
128
|
+
const visibility = normalizeId(
|
|
129
|
+
record.visibility
|
|
130
|
+
|| record.scope?.visibility
|
|
131
|
+
|| record.metadata?.visibility
|
|
132
|
+
|| record.metadata?.scope
|
|
133
|
+
);
|
|
134
|
+
return record.shared === true
|
|
135
|
+
|| record.scope?.shared === true
|
|
136
|
+
|| record.metadata?.shared === true
|
|
137
|
+
|| ['shared', 'global', 'public', 'team'].includes(String(visibility || '').toLowerCase());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function recordFingerprint(record = {}) {
|
|
141
|
+
const text = [
|
|
142
|
+
record.title,
|
|
143
|
+
record.content,
|
|
144
|
+
record.context,
|
|
145
|
+
record.whatWentWrong,
|
|
146
|
+
record.whatToChange,
|
|
147
|
+
record.whatWorked,
|
|
148
|
+
]
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.join(' ')
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/\s+/g, ' ')
|
|
153
|
+
.trim();
|
|
154
|
+
return text || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildMemoryScopeReadinessReport(records = []) {
|
|
158
|
+
const byScope = new Map();
|
|
159
|
+
const byFingerprint = new Map();
|
|
160
|
+
const missingFieldsByRecord = [];
|
|
161
|
+
let sharedRecords = 0;
|
|
162
|
+
let readyRecords = 0;
|
|
163
|
+
|
|
164
|
+
records.forEach((record, index) => {
|
|
165
|
+
const scope = normalizeScope(record);
|
|
166
|
+
const missingFields = missingScopeFields(scope);
|
|
167
|
+
const shared = isSharedMemory(record);
|
|
168
|
+
const scopeKey = memoryScopeKey(scope);
|
|
169
|
+
const id = record.id || `record-${index}`;
|
|
170
|
+
|
|
171
|
+
if (shared) sharedRecords += 1;
|
|
172
|
+
if (missingFields.length === 0) readyRecords += 1;
|
|
173
|
+
else missingFieldsByRecord.push({ id, index, missingFields });
|
|
174
|
+
|
|
175
|
+
if (scopeKey) {
|
|
176
|
+
if (!byScope.has(scopeKey)) byScope.set(scopeKey, []);
|
|
177
|
+
byScope.get(scopeKey).push(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const fingerprint = recordFingerprint(record);
|
|
181
|
+
if (fingerprint && scopeKey && !shared) {
|
|
182
|
+
if (!byFingerprint.has(fingerprint)) byFingerprint.set(fingerprint, new Set());
|
|
183
|
+
byFingerprint.get(fingerprint).add(scopeKey);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const duplicateScopeKeys = [...byScope.entries()]
|
|
188
|
+
.filter(([, ids]) => ids.length > 1)
|
|
189
|
+
.map(([scopeKey, ids]) => ({ scopeKey, ids }));
|
|
190
|
+
|
|
191
|
+
const crossScopeDuplicates = [...byFingerprint.entries()]
|
|
192
|
+
.filter(([, scopeKeys]) => scopeKeys.size > 1)
|
|
193
|
+
.map(([fingerprint, scopeKeys]) => ({
|
|
194
|
+
fingerprint,
|
|
195
|
+
scopeKeys: [...scopeKeys].sort((a, b) => a.localeCompare(b)),
|
|
196
|
+
}));
|
|
197
|
+
|
|
198
|
+
const unscopedRecords = missingFieldsByRecord.length;
|
|
199
|
+
const riskLevel = unscopedRecords > 0 || crossScopeDuplicates.length > 0 ? 'high' : 'low';
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
totalRecords: records.length,
|
|
203
|
+
readyRecords,
|
|
204
|
+
unscopedRecords,
|
|
205
|
+
sharedRecords,
|
|
206
|
+
isolatedScopeCount: byScope.size,
|
|
207
|
+
duplicateScopeKeys,
|
|
208
|
+
crossScopeDuplicates,
|
|
209
|
+
missingFieldsByRecord,
|
|
210
|
+
requiredFields: [...REQUIRED_SCOPE_FIELDS],
|
|
211
|
+
riskLevel,
|
|
212
|
+
ready: riskLevel === 'low',
|
|
213
|
+
recommendations: buildRecommendations({ unscopedRecords, crossScopeDuplicates }),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildRecommendations({ unscopedRecords, crossScopeDuplicates }) {
|
|
218
|
+
const recommendations = [];
|
|
219
|
+
if (unscopedRecords > 0) {
|
|
220
|
+
recommendations.push('Attach entityId, projectId, processId, and sessionId before writing memories.');
|
|
221
|
+
}
|
|
222
|
+
if (crossScopeDuplicates.length > 0) {
|
|
223
|
+
recommendations.push('Mark intentional shared memories explicitly; otherwise dedupe within the same scope only.');
|
|
224
|
+
}
|
|
225
|
+
if (recommendations.length === 0) {
|
|
226
|
+
recommendations.push('Scope posture is ready for multi-user and multi-session memory retrieval.');
|
|
227
|
+
}
|
|
228
|
+
return recommendations;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function selectRecordsForScope(records = [], requestedScope = {}, options = {}) {
|
|
232
|
+
const requested = normalizeScope(requestedScope);
|
|
233
|
+
const requestedKey = memoryScopeKey(requested);
|
|
234
|
+
if (!requestedKey) {
|
|
235
|
+
throw new Error(`requested scope missing fields: ${missingScopeFields(requested).join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const includeShared = options.includeShared !== false;
|
|
239
|
+
const allowed = [];
|
|
240
|
+
const blocked = [];
|
|
241
|
+
|
|
242
|
+
for (const record of records) {
|
|
243
|
+
const shared = isSharedMemory(record);
|
|
244
|
+
const recordKey = memoryScopeKey(record);
|
|
245
|
+
if (recordKey === requestedKey || (includeShared && shared)) {
|
|
246
|
+
allowed.push(record);
|
|
247
|
+
} else {
|
|
248
|
+
blocked.push(record);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
requestedScope: requested,
|
|
254
|
+
requestedKey,
|
|
255
|
+
allowed,
|
|
256
|
+
blocked,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildMemoriStyleBenchmarkRecords() {
|
|
261
|
+
return [
|
|
262
|
+
{
|
|
263
|
+
id: 'alice-agent-a-session-1',
|
|
264
|
+
entityId: 'alice',
|
|
265
|
+
projectId: 'thumbgate',
|
|
266
|
+
processId: 'agent-a',
|
|
267
|
+
sessionId: 'session-1',
|
|
268
|
+
content: 'Use the paid sprint checklist before changing checkout code.',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'bob-agent-a-session-1',
|
|
272
|
+
entityId: 'bob',
|
|
273
|
+
projectId: 'thumbgate',
|
|
274
|
+
processId: 'agent-a',
|
|
275
|
+
sessionId: 'session-1',
|
|
276
|
+
content: 'Bob private onboarding note.',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'alice-agent-b-session-1',
|
|
280
|
+
entityId: 'alice',
|
|
281
|
+
projectId: 'thumbgate',
|
|
282
|
+
processId: 'agent-b',
|
|
283
|
+
sessionId: 'session-1',
|
|
284
|
+
content: 'Agent B should use docs-only mode.',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 'alice-agent-a-session-2',
|
|
288
|
+
entityId: 'alice',
|
|
289
|
+
projectId: 'thumbgate',
|
|
290
|
+
processId: 'agent-a',
|
|
291
|
+
sessionId: 'session-2',
|
|
292
|
+
content: 'Session 2 has a different migration plan.',
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'team-shared-checkout-rule',
|
|
296
|
+
entityId: 'alice',
|
|
297
|
+
projectId: 'thumbgate',
|
|
298
|
+
processId: 'agent-a',
|
|
299
|
+
sessionId: 'session-1',
|
|
300
|
+
visibility: 'shared',
|
|
301
|
+
content: 'Shared rule: checkout mutations require audit evidence.',
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = {
|
|
307
|
+
REQUIRED_SCOPE_FIELDS,
|
|
308
|
+
buildMemoriStyleBenchmarkRecords,
|
|
309
|
+
buildMemoryScopeReadinessReport,
|
|
310
|
+
isSharedMemory,
|
|
311
|
+
memoryScopeKey,
|
|
312
|
+
missingScopeFields,
|
|
313
|
+
normalizeScope,
|
|
314
|
+
selectRecordsForScope,
|
|
315
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-side Plausible custom event emitter.
|
|
5
|
+
*
|
|
6
|
+
* Why: CLAUDE.md anti-lying directive + the 2026-05-13 revenue-ROI
|
|
7
|
+
* critique flagged improvement #2 — "0/50 checkout sessions completed
|
|
8
|
+
* and you don't know why." The full /checkout/pro funnel already writes
|
|
9
|
+
* to local JSONL via appendBestEffortTelemetry(), but Plausible (where
|
|
10
|
+
* we actually look at funnel charts) only sees page-views, not the
|
|
11
|
+
* email-submitted / stripe-redirect-started transitions.
|
|
12
|
+
*
|
|
13
|
+
* This helper POSTs to the Plausible events API
|
|
14
|
+
* https://plausible.io/api/event
|
|
15
|
+
* so the three funnel transitions show up alongside page-views in the
|
|
16
|
+
* same dashboard.
|
|
17
|
+
*
|
|
18
|
+
* Hard guarantees:
|
|
19
|
+
* - Fire-and-forget. Never throws. Never blocks a 302 redirect.
|
|
20
|
+
* - Disabled when THUMBGATE_PLAUSIBLE_DISABLE=1 (CI / tests / opt-out).
|
|
21
|
+
* - 2-second hard timeout. The request socket is unref-ed so the
|
|
22
|
+
* process can exit without waiting for the response.
|
|
23
|
+
* - No PII. Only event name + UTM/CTA metadata (already public).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const https = require('node:https');
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PLAUSIBLE_DOMAIN = 'thumbgate-production.up.railway.app';
|
|
29
|
+
const PLAUSIBLE_ENDPOINT = 'https://plausible.io/api/event';
|
|
30
|
+
const REQUEST_TIMEOUT_MS = 2_000;
|
|
31
|
+
|
|
32
|
+
function isPlausibleDisabled() {
|
|
33
|
+
if (process.env.THUMBGATE_PLAUSIBLE_DISABLE === '1') return true;
|
|
34
|
+
if (process.env.DO_NOT_TRACK === '1') return true;
|
|
35
|
+
// NODE_ENV-based detection was tried and dropped: `node --test` does not
|
|
36
|
+
// set NODE_ENV automatically, so the check produced surprising behavior
|
|
37
|
+
// in test vs. production. Tests must opt out explicitly via the dedicated
|
|
38
|
+
// THUMBGATE_PLAUSIBLE_DISABLE flag.
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getPlausibleDomain() {
|
|
43
|
+
return process.env.THUMBGATE_PLAUSIBLE_DOMAIN || DEFAULT_PLAUSIBLE_DOMAIN;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map raw telemetry fields onto Plausible "props" (string-only). Drop
|
|
48
|
+
* undefined values; cap each prop at 100 chars to satisfy Plausible's limits.
|
|
49
|
+
*/
|
|
50
|
+
function buildProps(metadata = {}) {
|
|
51
|
+
const props = {};
|
|
52
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
53
|
+
if (value === undefined || value === null || value === '') continue;
|
|
54
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
55
|
+
if (str.length === 0) continue;
|
|
56
|
+
props[key] = str.length > 100 ? `${str.slice(0, 97)}...` : str;
|
|
57
|
+
}
|
|
58
|
+
return props;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildPayload(eventName, options = {}) {
|
|
62
|
+
const domain = options.domain || getPlausibleDomain();
|
|
63
|
+
const pageUrl = options.pageUrl || `https://${domain}${options.page || '/'}`;
|
|
64
|
+
return {
|
|
65
|
+
name: eventName,
|
|
66
|
+
url: pageUrl,
|
|
67
|
+
domain,
|
|
68
|
+
referrer: options.referrer || undefined,
|
|
69
|
+
props: buildProps(options.props || {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Send a custom event to Plausible. Always returns a Promise that resolves —
|
|
75
|
+
* the caller can `void plausibleEvent(...)` and ignore. Resolved value is
|
|
76
|
+
* { sent, reason } so tests can assert the dispatch path was taken.
|
|
77
|
+
*/
|
|
78
|
+
function plausibleEvent(eventName, options = {}) {
|
|
79
|
+
if (isPlausibleDisabled()) {
|
|
80
|
+
return Promise.resolve({ sent: false, reason: 'disabled' });
|
|
81
|
+
}
|
|
82
|
+
if (typeof eventName !== 'string' || !eventName.trim()) {
|
|
83
|
+
return Promise.resolve({ sent: false, reason: 'invalid_event_name' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const payload = JSON.stringify(buildPayload(eventName, options));
|
|
87
|
+
const url = new URL(PLAUSIBLE_ENDPOINT);
|
|
88
|
+
const userAgent =
|
|
89
|
+
options.userAgent ||
|
|
90
|
+
`thumbgate-server/${process.env.npm_package_version || 'unknown'} (+https://github.com/IgorGanapolsky/ThumbGate)`;
|
|
91
|
+
const xff = options.forwardedFor || options.remoteAddr;
|
|
92
|
+
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
const done = (result) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
resolve(result);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const req = https.request(
|
|
103
|
+
{
|
|
104
|
+
hostname: url.hostname,
|
|
105
|
+
port: 443,
|
|
106
|
+
path: url.pathname,
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
111
|
+
'User-Agent': userAgent,
|
|
112
|
+
...(xff ? { 'X-Forwarded-For': xff } : {}),
|
|
113
|
+
},
|
|
114
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
115
|
+
},
|
|
116
|
+
(res) => {
|
|
117
|
+
// Plausible returns 202 on accept. Drain the response body so the
|
|
118
|
+
// socket can be released back to the pool, then resolve.
|
|
119
|
+
res.on('data', () => {});
|
|
120
|
+
res.on('end', () => done({ sent: true, statusCode: res.statusCode }));
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
req.on('error', () => done({ sent: false, reason: 'request_error' }));
|
|
124
|
+
req.on('timeout', () => {
|
|
125
|
+
req.destroy();
|
|
126
|
+
done({ sent: false, reason: 'timeout' });
|
|
127
|
+
});
|
|
128
|
+
req.on('socket', (s) => s.unref());
|
|
129
|
+
req.end(payload);
|
|
130
|
+
} catch {
|
|
131
|
+
done({ sent: false, reason: 'exception' });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convenience: fire the three /checkout/pro funnel events by name without
|
|
138
|
+
* each caller having to know the canonical Plausible event labels.
|
|
139
|
+
* The labels are chosen to read cleanly in the Plausible UI's event list.
|
|
140
|
+
*/
|
|
141
|
+
const CHECKOUT_EVENT_NAMES = Object.freeze({
|
|
142
|
+
view: 'Checkout Pro Viewed',
|
|
143
|
+
emailSubmitted: 'Checkout Pro Email Submitted',
|
|
144
|
+
stripeRedirect: 'Checkout Pro Stripe Redirect Started',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function recordCheckoutFunnelEvent(stage, options = {}) {
|
|
148
|
+
const name = CHECKOUT_EVENT_NAMES[stage];
|
|
149
|
+
if (!name) return Promise.resolve({ sent: false, reason: 'unknown_stage' });
|
|
150
|
+
return plausibleEvent(name, options);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
plausibleEvent,
|
|
155
|
+
recordCheckoutFunnelEvent,
|
|
156
|
+
buildPayload,
|
|
157
|
+
buildProps,
|
|
158
|
+
isPlausibleDisabled,
|
|
159
|
+
getPlausibleDomain,
|
|
160
|
+
CHECKOUT_EVENT_NAMES,
|
|
161
|
+
PLAUSIBLE_ENDPOINT,
|
|
162
|
+
};
|