thumbgate 1.5.0 → 1.5.1
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 +230 -228
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -2
- package/adapters/mcp/server-stdio.js +34 -3
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +21 -8
- package/bin/postinstall.js +25 -17
- package/config/evals/agent-safety-eval.json +131 -0
- package/config/github-about.json +5 -2
- package/config/specs/agent-safety.json +79 -0
- package/package.json +44 -8
- package/public/compare.html +3 -3
- package/public/guide.html +2 -2
- package/public/index.html +230 -98
- package/scripts/auto-wire-hooks.js +77 -27
- package/scripts/bot-detection.js +165 -0
- package/scripts/cli-feedback.js +6 -2
- package/scripts/commercial-offer.js +4 -4
- package/scripts/dashboard.js +152 -2
- package/scripts/decision-trace.js +354 -0
- package/scripts/feedback-loop.js +4 -8
- package/scripts/rate-limiter.js +77 -24
- package/scripts/sales-pipeline.js +681 -0
- package/scripts/session-episode-store.js +329 -0
- package/scripts/session-health-sensor.js +242 -0
- package/scripts/spec-gate.js +362 -0
- package/scripts/statusline.sh +6 -9
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +368 -12
package/src/api/server.js
CHANGED
|
@@ -80,6 +80,9 @@ const {
|
|
|
80
80
|
const {
|
|
81
81
|
bootstrapInternalAgent,
|
|
82
82
|
} = require('../../scripts/internal-agent-bootstrap');
|
|
83
|
+
const {
|
|
84
|
+
classifyRequester,
|
|
85
|
+
} = require('../../scripts/bot-detection');
|
|
83
86
|
const {
|
|
84
87
|
buildCloudflareSandboxPlan,
|
|
85
88
|
} = require('../../scripts/cloudflare-dynamic-sandbox');
|
|
@@ -218,7 +221,58 @@ const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
|
|
|
218
221
|
const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
|
|
219
222
|
const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
|
|
220
223
|
const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
|
|
224
|
+
const PUBLIC_DIR = path.resolve(__dirname, '../../public');
|
|
225
|
+
const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
|
|
221
226
|
const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
|
|
227
|
+
const STATIC_MIME_BY_EXT = Object.freeze({
|
|
228
|
+
'.png': 'image/png',
|
|
229
|
+
'.jpg': 'image/jpeg',
|
|
230
|
+
'.jpeg': 'image/jpeg',
|
|
231
|
+
'.gif': 'image/gif',
|
|
232
|
+
'.webp': 'image/webp',
|
|
233
|
+
'.svg': 'image/svg+xml',
|
|
234
|
+
'.ico': 'image/x-icon',
|
|
235
|
+
'.mp4': 'video/mp4',
|
|
236
|
+
'.webm': 'video/webm',
|
|
237
|
+
'.mp3': 'audio/mpeg',
|
|
238
|
+
'.ogg': 'audio/ogg',
|
|
239
|
+
'.wav': 'audio/wav',
|
|
240
|
+
'.pdf': 'application/pdf',
|
|
241
|
+
'.woff': 'font/woff',
|
|
242
|
+
'.woff2': 'font/woff2',
|
|
243
|
+
'.ttf': 'font/ttf',
|
|
244
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
245
|
+
'.json': 'application/json; charset=utf-8',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400 } = {}) {
|
|
249
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
250
|
+
const contentType = STATIC_MIME_BY_EXT[ext] || 'application/octet-stream';
|
|
251
|
+
let stat;
|
|
252
|
+
try {
|
|
253
|
+
stat = fs.statSync(filePath);
|
|
254
|
+
} catch {
|
|
255
|
+
res.statusCode = 404;
|
|
256
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
257
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!stat.isFile()) {
|
|
261
|
+
res.statusCode = 404;
|
|
262
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
263
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
res.statusCode = 200;
|
|
267
|
+
res.setHeader('Content-Type', contentType);
|
|
268
|
+
res.setHeader('Content-Length', stat.size);
|
|
269
|
+
res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
|
|
270
|
+
if (headOnly) {
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
fs.createReadStream(filePath).pipe(res);
|
|
275
|
+
}
|
|
222
276
|
const VISITOR_COOKIE_NAME = 'thumbgate_visitor_id';
|
|
223
277
|
const SESSION_COOKIE_NAME = 'thumbgate_session_id';
|
|
224
278
|
const ACQUISITION_COOKIE_NAME = 'thumbgate_acquisition_id';
|
|
@@ -1211,6 +1265,13 @@ function getPublicOrigin(req) {
|
|
|
1211
1265
|
return `${proto}://${host}`;
|
|
1212
1266
|
}
|
|
1213
1267
|
|
|
1268
|
+
function renderOpenApiYamlForRequest(yaml, req) {
|
|
1269
|
+
return yaml.replace(
|
|
1270
|
+
/servers:\n\s+- url: .+/m,
|
|
1271
|
+
`servers:\n - url: ${getPublicOrigin(req)}`
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1214
1275
|
function getRequestHostHeader(req) {
|
|
1215
1276
|
const forwardedHost = req.headers['x-forwarded-host'];
|
|
1216
1277
|
if (Array.isArray(forwardedHost)) {
|
|
@@ -1463,8 +1524,21 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
1463
1524
|
const context = merged.context || '';
|
|
1464
1525
|
const whatWentWrong = merged.whatWentWrong || '';
|
|
1465
1526
|
const whatWorked = merged.whatWorked || '';
|
|
1527
|
+
const whatToChange = merged.whatToChange || '';
|
|
1466
1528
|
const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
|
|
1467
1529
|
const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
|
|
1530
|
+
const isoTimestamp = merged.timestamp || '';
|
|
1531
|
+
|
|
1532
|
+
// Technical metadata
|
|
1533
|
+
const failureType = merged.failureType || null;
|
|
1534
|
+
const skill = merged.skill || null;
|
|
1535
|
+
const source = merged.source || fb.source || null;
|
|
1536
|
+
const relatedFeedbackId = merged.relatedFeedbackId || null;
|
|
1537
|
+
const promotedToMemory = !!mem.id || !!merged.promotedToMemory;
|
|
1538
|
+
const feedbackEventId = fb.id || null;
|
|
1539
|
+
const memoryRecordId = mem.id || null;
|
|
1540
|
+
const guardrails = merged.guardrails || null;
|
|
1541
|
+
const rubricScores = merged.rubricScores || null;
|
|
1468
1542
|
|
|
1469
1543
|
// Structured rule
|
|
1470
1544
|
const rule = merged.structuredRule || merged.rule || null;
|
|
@@ -1499,12 +1573,25 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
1499
1573
|
|
|
1500
1574
|
let convoHtml = '';
|
|
1501
1575
|
if (Array.isArray(convoWindow) && convoWindow.length > 0) {
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1576
|
+
const seen = new Set();
|
|
1577
|
+
const validMsgs = convoWindow.filter((m) => {
|
|
1578
|
+
const text = m.content || m.text || '';
|
|
1579
|
+
if (typeof text === 'string' ? text.trim().length === 0 : !text) return false;
|
|
1580
|
+
const dedupeKey = (m.role || m.author || '') + '|' + (typeof text === 'string' ? text.trim() : JSON.stringify(text));
|
|
1581
|
+
if (seen.has(dedupeKey)) return false;
|
|
1582
|
+
seen.add(dedupeKey);
|
|
1583
|
+
return true;
|
|
1584
|
+
});
|
|
1585
|
+
if (validMsgs.length > 0) {
|
|
1586
|
+
const msgs = validMsgs.map((m) => {
|
|
1587
|
+
const role = esc(m.role || m.author || 'system');
|
|
1588
|
+
const content = esc(typeof (m.content || m.text) === 'string' ? (m.content || m.text) : JSON.stringify(m.content || m.text));
|
|
1589
|
+
const ts = m.timestamp ? `<span style="color:var(--text-muted);font-size:10px;margin-left:8px">${esc(new Date(m.timestamp).toLocaleString())}</span>` : '';
|
|
1590
|
+
const src = m.source ? `<span style="color:var(--text-muted);font-size:10px;margin-left:8px;opacity:0.6">${esc(m.source)}</span>` : '';
|
|
1591
|
+
return `<div class="convo-msg"><span class="convo-role">${role}</span>${ts}${src}<div class="convo-content" style="margin-top:4px">${content}</div></div>`;
|
|
1592
|
+
}).join('');
|
|
1593
|
+
convoHtml = sectionCard('Conversation Window', `<div class="convo-list">${msgs}</div>`, 'convoWindow');
|
|
1594
|
+
}
|
|
1508
1595
|
}
|
|
1509
1596
|
|
|
1510
1597
|
let reflectorHtml = '';
|
|
@@ -1551,6 +1638,51 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
1551
1638
|
if (parts.length) bayesianHtml = sectionCard('Bayesian Belief', `<table class="detail-table">${parts.join('')}</table>`, 'bayesian');
|
|
1552
1639
|
}
|
|
1553
1640
|
|
|
1641
|
+
// Technical metadata section
|
|
1642
|
+
const techParts = [];
|
|
1643
|
+
techParts.push(`<tr><td class="label">Feedback Event ID</td><td><code>${esc(feedbackEventId || lessonId)}</code></td></tr>`);
|
|
1644
|
+
if (memoryRecordId) techParts.push(`<tr><td class="label">Memory Record ID</td><td><code>${esc(memoryRecordId)}</code></td></tr>`);
|
|
1645
|
+
techParts.push(`<tr><td class="label">Promoted to Memory</td><td>${promotedToMemory ? '<span style="color:var(--green)">✓ Yes</span>' : '<span style="color:var(--text-muted)">✗ No</span>'}</td></tr>`);
|
|
1646
|
+
if (failureType) techParts.push(`<tr><td class="label">Failure Type</td><td><span style="color:${failureType === 'decision' ? 'var(--yellow)' : 'var(--purple)'};font-weight:600">${esc(failureType)}</span> <span style="color:var(--text-muted);font-size:11px">${failureType === 'decision' ? '(wrong tool/action chosen)' : '(right tool, bad params/output)'}</span></td></tr>`);
|
|
1647
|
+
if (skill) techParts.push(`<tr><td class="label">Skill</td><td><code>${esc(skill)}</code></td></tr>`);
|
|
1648
|
+
if (source) techParts.push(`<tr><td class="label">Source</td><td>${esc(source)}</td></tr>`);
|
|
1649
|
+
if (relatedFeedbackId) techParts.push(`<tr><td class="label">Related Feedback</td><td><a href="/lessons/${esc(relatedFeedbackId)}" style="color:var(--cyan)">${esc(relatedFeedbackId)}</a></td></tr>`);
|
|
1650
|
+
if (isoTimestamp) techParts.push(`<tr><td class="label">ISO Timestamp</td><td><code>${esc(isoTimestamp)}</code></td></tr>`);
|
|
1651
|
+
const techMetadataHtml = sectionCard('Technical Metadata', `<table class="detail-table">${techParts.join('')}</table>`, 'techMetadata');
|
|
1652
|
+
|
|
1653
|
+
// What to Change section (for negative feedback)
|
|
1654
|
+
let whatToChangeHtml = '';
|
|
1655
|
+
if (whatToChange) {
|
|
1656
|
+
whatToChangeHtml = sectionCard('What to Change', `<div style="padding:12px;background:var(--bg-raised);border-radius:8px;font-size:13px;color:var(--text-muted);white-space:pre-wrap">${esc(whatToChange)}</div>`, 'whatToChange');
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// Guardrails section
|
|
1660
|
+
let guardrailsHtml = '';
|
|
1661
|
+
if (guardrails && typeof guardrails === 'object') {
|
|
1662
|
+
const gParts = [];
|
|
1663
|
+
if (guardrails.testsPassed !== undefined) gParts.push(`<tr><td class="label">Tests Passed</td><td>${guardrails.testsPassed ? '<span style="color:var(--green)">✓</span>' : '<span style="color:var(--red)">✗</span>'}</td></tr>`);
|
|
1664
|
+
if (guardrails.pathSafety !== undefined) gParts.push(`<tr><td class="label">Path Safety</td><td>${guardrails.pathSafety ? '<span style="color:var(--green)">✓</span>' : '<span style="color:var(--red)">✗</span>'}</td></tr>`);
|
|
1665
|
+
if (guardrails.budgetCompliant !== undefined) gParts.push(`<tr><td class="label">Budget Compliant</td><td>${guardrails.budgetCompliant ? '<span style="color:var(--green)">✓</span>' : '<span style="color:var(--red)">✗</span>'}</td></tr>`);
|
|
1666
|
+
if (gParts.length) guardrailsHtml = sectionCard('Guardrails', `<table class="detail-table">${gParts.join('')}</table>`, 'guardrails');
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Rubric Scores section (from capture, distinct from rubric evaluation)
|
|
1670
|
+
let rubricScoresHtml = '';
|
|
1671
|
+
if (Array.isArray(rubricScores) && rubricScores.length > 0) {
|
|
1672
|
+
const rows = rubricScores.map(s => `<tr><td class="label">${esc(s.criterion || '')}</td><td><span style="font-weight:700;color:${(s.score || 0) >= 0.7 ? 'var(--green)' : (s.score || 0) >= 0.4 ? 'var(--yellow)' : 'var(--red)'}">${esc(String(s.score || 0))}</span> <span style="color:var(--text-muted);font-size:11px">${s.judge ? 'by ' + esc(s.judge) : ''}</span>${s.evidence ? `<div style="margin-top:4px;font-size:12px;color:var(--text-muted)">${esc(s.evidence)}</div>` : ''}</td></tr>`).join('');
|
|
1673
|
+
rubricScoresHtml = sectionCard('Rubric Scores', `<table class="detail-table">${rows}</table>`, 'rubricScores');
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Raw JSON (collapsible)
|
|
1677
|
+
const rawJson = JSON.stringify(record, null, 2);
|
|
1678
|
+
const rawJsonHtml = `<div class="detail-card" id="rawJson">
|
|
1679
|
+
<h3 style="cursor:pointer" onclick="var el=document.getElementById('rawJsonContent');el.style.display=el.style.display==='none'?'block':'none';this.textContent=el.style.display==='none'?'Raw JSON ▸':'Raw JSON ▾'">Raw JSON ▸</h3>
|
|
1680
|
+
<div id="rawJsonContent" style="display:none">
|
|
1681
|
+
<button class="btn btn-secondary" style="margin-bottom:12px;padding:6px 16px;font-size:12px" onclick="navigator.clipboard.writeText(${esc(JSON.stringify(rawJson))}).then(()=>showToast('JSON copied!','success'))">📋 Copy JSON</button>
|
|
1682
|
+
<pre style="background:var(--bg-raised);border:1px solid var(--border);border-radius:8px;padding:16px;font-size:11px;font-family:var(--mono);overflow-x:auto;max-height:600px;overflow-y:auto;color:var(--text-muted)">${esc(rawJson)}</pre>
|
|
1683
|
+
</div>
|
|
1684
|
+
</div>`;
|
|
1685
|
+
|
|
1554
1686
|
return `<!DOCTYPE html>
|
|
1555
1687
|
<html lang="en">
|
|
1556
1688
|
<head>
|
|
@@ -1657,6 +1789,10 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
|
|
|
1657
1789
|
</div>
|
|
1658
1790
|
</div>
|
|
1659
1791
|
|
|
1792
|
+
${techMetadataHtml}
|
|
1793
|
+
${whatToChangeHtml}
|
|
1794
|
+
${guardrailsHtml}
|
|
1795
|
+
${rubricScoresHtml}
|
|
1660
1796
|
${structuredRuleHtml}
|
|
1661
1797
|
${convoHtml}
|
|
1662
1798
|
${reflectorHtml}
|
|
@@ -1664,6 +1800,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
|
|
|
1664
1800
|
${rubricHtml}
|
|
1665
1801
|
${synthesisHtml}
|
|
1666
1802
|
${bayesianHtml}
|
|
1803
|
+
${rawJsonHtml}
|
|
1667
1804
|
|
|
1668
1805
|
<div class="actions-bar">
|
|
1669
1806
|
<button class="btn btn-primary" onclick="saveChanges()">Save Changes</button>
|
|
@@ -3426,6 +3563,27 @@ async function addContext(){
|
|
|
3426
3563
|
return;
|
|
3427
3564
|
}
|
|
3428
3565
|
|
|
3566
|
+
if (isGetLikeRequest && pathname.startsWith('/assets/')) {
|
|
3567
|
+
const rel = pathname.slice('/assets/'.length);
|
|
3568
|
+
const resolved = path.resolve(PUBLIC_ASSETS_DIR, rel);
|
|
3569
|
+
if (!resolved.startsWith(PUBLIC_ASSETS_DIR + path.sep) && resolved !== PUBLIC_ASSETS_DIR) {
|
|
3570
|
+
sendJson(res, 403, { error: 'Forbidden' });
|
|
3571
|
+
return;
|
|
3572
|
+
}
|
|
3573
|
+
serveStaticFile(res, resolved, { headOnly: isHeadRequest });
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
if (isGetLikeRequest && (
|
|
3578
|
+
pathname === '/favicon.ico'
|
|
3579
|
+
|| pathname === '/thumbgate-logo.png'
|
|
3580
|
+
|| pathname === '/og.png'
|
|
3581
|
+
|| pathname === '/apple-touch-icon.png'
|
|
3582
|
+
)) {
|
|
3583
|
+
serveStaticFile(res, path.join(PUBLIC_DIR, pathname.slice(1)), { headOnly: isHeadRequest });
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3429
3587
|
if (isGetLikeRequest && pathname === '/') {
|
|
3430
3588
|
if (wantsJson(req, parsed)) {
|
|
3431
3589
|
sendJson(res, 200, {
|
|
@@ -3475,6 +3633,36 @@ async function addContext(){
|
|
|
3475
3633
|
? { 'Set-Cookie': journeyState.setCookieHeaders }
|
|
3476
3634
|
: {};
|
|
3477
3635
|
|
|
3636
|
+
// ── Bot guard ────────────────────────────────────────────────────
|
|
3637
|
+
// Creating a Stripe Checkout session on every GET means crawlers,
|
|
3638
|
+
// link-preview fetchers, and LLM scrapers inflate "sessions opened"
|
|
3639
|
+
// while completions stay at zero. Serve bots an interstitial HTML
|
|
3640
|
+
// page instead — no Stripe session created, no funnel pollution.
|
|
3641
|
+
// The `?confirm=1` query param or POST below is the real-user path.
|
|
3642
|
+
const botClassification = classifyRequester(req.headers);
|
|
3643
|
+
const confirmParam = parsed?.searchParams?.get('confirm') ?? null;
|
|
3644
|
+
const isConfirmedCheckout = confirmParam === '1'
|
|
3645
|
+
|| confirmParam === 'true'
|
|
3646
|
+
|| req.method === 'POST';
|
|
3647
|
+
if (botClassification.isBot && !isConfirmedCheckout) {
|
|
3648
|
+
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
3649
|
+
eventType: 'checkout_bot_deflected',
|
|
3650
|
+
clientType: 'web',
|
|
3651
|
+
traceId,
|
|
3652
|
+
utmSource: analyticsMetadata.utmSource,
|
|
3653
|
+
utmMedium: analyticsMetadata.utmMedium,
|
|
3654
|
+
utmCampaign: analyticsMetadata.utmCampaign,
|
|
3655
|
+
referrer: analyticsMetadata.referrer,
|
|
3656
|
+
referrerHost: analyticsMetadata.referrerHost,
|
|
3657
|
+
page: '/checkout/pro',
|
|
3658
|
+
planId: analyticsMetadata.planId,
|
|
3659
|
+
reason: botClassification.reason,
|
|
3660
|
+
}, req.headers, 'checkout_bot_deflected');
|
|
3661
|
+
const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>ThumbGate Pro \u2014 Confirm checkout</title><style>*{box-sizing:border-box}body{background:#0a0a0a;color:#e5e5e5;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px}.card{background:#141414;border:1px solid #222;border-radius:16px;padding:48px 40px;max-width:460px;width:100%;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.5)}h1{margin:0 0 12px;font-size:22px;color:#22d3ee}p{color:#9ca3af;font-size:14px;line-height:1.55;margin:0 0 24px}.btn{display:inline-block;background:#22d3ee;color:#000;text-decoration:none;font-weight:700;padding:14px 32px;border-radius:999px;font-size:16px;cursor:pointer;border:none}.btn:hover{opacity:.9}.sub{margin-top:16px;font-size:12px;color:#6b7280}a.back{color:#6b7280;font-size:13px;text-decoration:underline}</style></head><body><div class="card"><h1>Continue to secure checkout</h1><p>You\'re one click from ThumbGate Pro at $19/mo. We create the payment session only after you confirm \u2014 keeps your path clean and our funnel honest.</p><a class="btn" href="/checkout/pro?confirm=1" rel="noopener">Continue to Stripe \u2192</a><div class="sub">Payments handled by Stripe. 7-day free trial. Cancel anytime.</div><div class="sub"><a class="back" href="/">\u2190 Back to homepage</a></div></div></body></html>';
|
|
3662
|
+
sendHtml(res, 200, html, responseHeaders);
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3478
3666
|
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
3479
3667
|
eventType: 'checkout_bootstrap',
|
|
3480
3668
|
clientType: 'web',
|
|
@@ -3956,18 +4144,23 @@ async function addContext(){
|
|
|
3956
4144
|
}
|
|
3957
4145
|
|
|
3958
4146
|
// Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
|
|
3959
|
-
if (isGetLikeRequest && pathname === '/openapi.json') {
|
|
4147
|
+
if (isGetLikeRequest && (pathname === '/openapi.json' || pathname === '/openapi.yaml')) {
|
|
3960
4148
|
const specPath = path.join(__dirname, '../../adapters/chatgpt/openapi.yaml');
|
|
3961
4149
|
try {
|
|
3962
|
-
const yaml = fs.readFileSync(specPath, 'utf8');
|
|
4150
|
+
const yaml = renderOpenApiYamlForRequest(fs.readFileSync(specPath, 'utf8'), req);
|
|
4151
|
+
if (pathname === '/openapi.yaml') {
|
|
4152
|
+
sendText(res, 200, yaml, {
|
|
4153
|
+
'Content-Type': 'text/yaml; charset=utf-8',
|
|
4154
|
+
'Access-Control-Allow-Origin': '*',
|
|
4155
|
+
}, {
|
|
4156
|
+
headOnly: isHeadRequest,
|
|
4157
|
+
});
|
|
4158
|
+
return;
|
|
4159
|
+
}
|
|
3963
4160
|
// Convert YAML to JSON inline (simple key:value conversion via js-yaml if available, else serve as-is)
|
|
3964
4161
|
try {
|
|
3965
4162
|
const jsYaml = require('js-yaml');
|
|
3966
4163
|
const spec = jsYaml.load(yaml);
|
|
3967
|
-
// Override server URL to current deployment
|
|
3968
|
-
if (spec.servers && spec.servers[0]) {
|
|
3969
|
-
spec.servers[0].url = `${req.headers['x-forwarded-proto'] || 'https'}://${req.headers.host}`;
|
|
3970
|
-
}
|
|
3971
4164
|
sendJson(res, 200, spec, {
|
|
3972
4165
|
'Access-Control-Allow-Origin': '*',
|
|
3973
4166
|
}, {
|
|
@@ -4808,6 +5001,169 @@ async function addContext(){
|
|
|
4808
5001
|
return;
|
|
4809
5002
|
}
|
|
4810
5003
|
|
|
5004
|
+
// --- Team Lesson Export: POST /v1/lessons/export ---
|
|
5005
|
+
if (req.method === 'POST' && pathname === '/v1/lessons/export') {
|
|
5006
|
+
const body = await parseJsonBody(req);
|
|
5007
|
+
const feedbackDir = requestSafeDataDir;
|
|
5008
|
+
const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
|
|
5009
|
+
const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
|
|
5010
|
+
const memories = readJSONLLocal(memoryLogPath, { maxLines: 0 });
|
|
5011
|
+
const feedbacks = readJSONLLocal(feedbackLogPath, { maxLines: 0 });
|
|
5012
|
+
|
|
5013
|
+
// Merge into unified lesson records
|
|
5014
|
+
const lessonMap = new Map();
|
|
5015
|
+
for (const rec of feedbacks) {
|
|
5016
|
+
if (rec.id) lessonMap.set(rec.id, { feedbackEvent: rec, memoryRecord: null });
|
|
5017
|
+
}
|
|
5018
|
+
for (const rec of memories) {
|
|
5019
|
+
if (rec.id) {
|
|
5020
|
+
const existing = lessonMap.get(rec.id);
|
|
5021
|
+
if (existing) { existing.memoryRecord = rec; }
|
|
5022
|
+
else { lessonMap.set(rec.id, { feedbackEvent: null, memoryRecord: rec }); }
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
// Filter by tags/signal if requested
|
|
5027
|
+
const filterTags = Array.isArray(body.tags) ? body.tags : [];
|
|
5028
|
+
const filterSignal = body.signal || null; // 'up' | 'down' | null
|
|
5029
|
+
let lessons = Array.from(lessonMap.values());
|
|
5030
|
+
if (filterTags.length > 0) {
|
|
5031
|
+
lessons = lessons.filter((l) => {
|
|
5032
|
+
const merged = { ...(l.feedbackEvent || {}), ...(l.memoryRecord || {}) };
|
|
5033
|
+
const tags = Array.isArray(merged.tags) ? merged.tags : [];
|
|
5034
|
+
return filterTags.some((t) => tags.includes(t));
|
|
5035
|
+
});
|
|
5036
|
+
}
|
|
5037
|
+
if (filterSignal) {
|
|
5038
|
+
lessons = lessons.filter((l) => {
|
|
5039
|
+
const merged = { ...(l.feedbackEvent || {}), ...(l.memoryRecord || {}) };
|
|
5040
|
+
return normalizeLessonSignal(merged.signal) === filterSignal;
|
|
5041
|
+
});
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
const bundle = {
|
|
5045
|
+
version: '1.0.0',
|
|
5046
|
+
exportedAt: new Date().toISOString(),
|
|
5047
|
+
source: {
|
|
5048
|
+
project: path.basename(feedbackDir),
|
|
5049
|
+
hostname: require('os').hostname(),
|
|
5050
|
+
},
|
|
5051
|
+
lessonCount: lessons.length,
|
|
5052
|
+
lessons: lessons.map((l) => {
|
|
5053
|
+
const merged = { ...(l.feedbackEvent || {}), ...(l.memoryRecord || {}) };
|
|
5054
|
+
return {
|
|
5055
|
+
id: merged.id,
|
|
5056
|
+
signal: normalizeLessonSignal(merged.signal),
|
|
5057
|
+
title: merged.title || merged.context || '',
|
|
5058
|
+
context: merged.context || '',
|
|
5059
|
+
whatWentWrong: merged.whatWentWrong || '',
|
|
5060
|
+
whatWorked: merged.whatWorked || '',
|
|
5061
|
+
whatToChange: merged.whatToChange || '',
|
|
5062
|
+
tags: Array.isArray(merged.tags) ? merged.tags : [],
|
|
5063
|
+
timestamp: merged.timestamp || null,
|
|
5064
|
+
failureType: merged.failureType || null,
|
|
5065
|
+
skill: merged.skill || null,
|
|
5066
|
+
structuredRule: merged.structuredRule || merged.rule || null,
|
|
5067
|
+
diagnosis: merged.diagnosis || null,
|
|
5068
|
+
};
|
|
5069
|
+
}),
|
|
5070
|
+
};
|
|
5071
|
+
|
|
5072
|
+
if (body.outputPath) {
|
|
5073
|
+
const safePath = resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir });
|
|
5074
|
+
fs.mkdirSync(path.dirname(safePath), { recursive: true });
|
|
5075
|
+
fs.writeFileSync(safePath, JSON.stringify(bundle, null, 2));
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5078
|
+
sendJson(res, 200, {
|
|
5079
|
+
exported: bundle.lessonCount,
|
|
5080
|
+
exportedAt: bundle.exportedAt,
|
|
5081
|
+
source: bundle.source,
|
|
5082
|
+
outputPath: body.outputPath || null,
|
|
5083
|
+
bundle: body.inline !== false ? bundle : undefined,
|
|
5084
|
+
});
|
|
5085
|
+
return;
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
// --- Team Lesson Import: POST /v1/lessons/import ---
|
|
5089
|
+
if (req.method === 'POST' && pathname === '/v1/lessons/import') {
|
|
5090
|
+
const body = await parseJsonBody(req);
|
|
5091
|
+
const bundle = body.bundle || body;
|
|
5092
|
+
if (!bundle.lessons || !Array.isArray(bundle.lessons)) {
|
|
5093
|
+
sendJson(res, 400, { error: 'Invalid bundle: missing lessons array' });
|
|
5094
|
+
return;
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
const feedbackDir = requestSafeDataDir;
|
|
5098
|
+
const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
|
|
5099
|
+
|
|
5100
|
+
// Load existing IDs for dedup
|
|
5101
|
+
const existing = readJSONLLocal(feedbackLogPath, { maxLines: 0 });
|
|
5102
|
+
const existingIds = new Set(existing.map((r) => r.id).filter(Boolean));
|
|
5103
|
+
// Also dedup by title+signal content hash
|
|
5104
|
+
const existingHashes = new Set(existing.map((r) => {
|
|
5105
|
+
const t = (r.title || r.context || '').trim().toLowerCase();
|
|
5106
|
+
const s = normalizeLessonSignal(r.signal);
|
|
5107
|
+
return `${s}|${t}`;
|
|
5108
|
+
}).filter((h) => h !== '|'));
|
|
5109
|
+
|
|
5110
|
+
let imported = 0;
|
|
5111
|
+
let skippedDuplicate = 0;
|
|
5112
|
+
const importedIds = [];
|
|
5113
|
+
|
|
5114
|
+
for (const lesson of bundle.lessons) {
|
|
5115
|
+
// Skip if exact ID exists
|
|
5116
|
+
if (lesson.id && existingIds.has(lesson.id)) {
|
|
5117
|
+
skippedDuplicate++;
|
|
5118
|
+
continue;
|
|
5119
|
+
}
|
|
5120
|
+
// Skip if same title+signal already exists (content dedup)
|
|
5121
|
+
const contentHash = `${normalizeLessonSignal(lesson.signal)}|${(lesson.title || lesson.context || '').trim().toLowerCase()}`;
|
|
5122
|
+
if (contentHash !== '|' && existingHashes.has(contentHash)) {
|
|
5123
|
+
skippedDuplicate++;
|
|
5124
|
+
continue;
|
|
5125
|
+
}
|
|
5126
|
+
|
|
5127
|
+
// Create imported record with provenance
|
|
5128
|
+
const importedRecord = {
|
|
5129
|
+
id: `imported_${Date.now()}_${require("crypto").randomBytes(4).toString("hex")}`,
|
|
5130
|
+
signal: lesson.signal || 'down',
|
|
5131
|
+
title: lesson.title || '',
|
|
5132
|
+
context: lesson.context || '',
|
|
5133
|
+
whatWentWrong: lesson.whatWentWrong || '',
|
|
5134
|
+
whatWorked: lesson.whatWorked || '',
|
|
5135
|
+
whatToChange: lesson.whatToChange || '',
|
|
5136
|
+
tags: [...(Array.isArray(lesson.tags) ? lesson.tags : []), 'team-import'],
|
|
5137
|
+
timestamp: new Date().toISOString(),
|
|
5138
|
+
failureType: lesson.failureType || null,
|
|
5139
|
+
skill: lesson.skill || null,
|
|
5140
|
+
structuredRule: lesson.structuredRule || null,
|
|
5141
|
+
diagnosis: lesson.diagnosis || null,
|
|
5142
|
+
provenance: {
|
|
5143
|
+
importedAt: new Date().toISOString(),
|
|
5144
|
+
originalId: lesson.id || null,
|
|
5145
|
+
source: bundle.source || null,
|
|
5146
|
+
exportedAt: bundle.exportedAt || null,
|
|
5147
|
+
},
|
|
5148
|
+
};
|
|
5149
|
+
|
|
5150
|
+
fs.appendFileSync(feedbackLogPath, JSON.stringify(importedRecord) + '\n', 'utf8');
|
|
5151
|
+
existingIds.add(importedRecord.id);
|
|
5152
|
+
existingHashes.add(contentHash);
|
|
5153
|
+
importedIds.push(importedRecord.id);
|
|
5154
|
+
imported++;
|
|
5155
|
+
}
|
|
5156
|
+
|
|
5157
|
+
sendJson(res, 200, {
|
|
5158
|
+
imported,
|
|
5159
|
+
skippedDuplicate,
|
|
5160
|
+
total: bundle.lessons.length,
|
|
5161
|
+
importedIds,
|
|
5162
|
+
source: bundle.source || null,
|
|
5163
|
+
});
|
|
5164
|
+
return;
|
|
5165
|
+
}
|
|
5166
|
+
|
|
4811
5167
|
if (req.method === 'POST' && pathname === '/v1/analytics/databricks/export') {
|
|
4812
5168
|
const body = await parseJsonBody(req);
|
|
4813
5169
|
const outputPath = body.outputPath
|