thumbgate 1.30.0 → 1.31.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/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +66 -8
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +147 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +212 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/package.json +60 -8
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +17 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +46 -0
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +194 -13
package/src/api/server.js
CHANGED
|
@@ -210,7 +210,7 @@ const {
|
|
|
210
210
|
getSettingsStatus,
|
|
211
211
|
} = require('../../scripts/settings-hierarchy');
|
|
212
212
|
const {
|
|
213
|
-
|
|
213
|
+
searchThumbgateAsync,
|
|
214
214
|
} = require('../../scripts/thumbgate-search');
|
|
215
215
|
const {
|
|
216
216
|
appendTelemetryPing,
|
|
@@ -480,6 +480,25 @@ const TRACKED_LINK_TARGETS = Object.freeze({
|
|
|
480
480
|
},
|
|
481
481
|
allowCustomerEmail: true,
|
|
482
482
|
},
|
|
483
|
+
// Human skip from Pro interstitial: same-origin hop so crawlers never see buy.stripe.com
|
|
484
|
+
// in the interstitial HTML (bot-guard contract). Bots are deflected to /checkout/pro.
|
|
485
|
+
'pro-direct': {
|
|
486
|
+
fallbackHref: PRO_CHECKOUT_URL,
|
|
487
|
+
external: true,
|
|
488
|
+
botDeflectPath: '/checkout/pro',
|
|
489
|
+
ctaId: 'pro_checkout_direct_stripe',
|
|
490
|
+
ctaPlacement: 'checkout_interstitial',
|
|
491
|
+
eventType: 'pro_checkout_direct_stripe',
|
|
492
|
+
defaults: {
|
|
493
|
+
utm_source: 'website',
|
|
494
|
+
utm_medium: 'checkout_interstitial',
|
|
495
|
+
utm_campaign: 'pro_upgrade',
|
|
496
|
+
plan_id: 'pro',
|
|
497
|
+
billing_cycle: 'monthly',
|
|
498
|
+
},
|
|
499
|
+
allowCustomerEmail: true,
|
|
500
|
+
prefillStripeEmail: true,
|
|
501
|
+
},
|
|
483
502
|
// 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
|
|
484
503
|
teams: {
|
|
485
504
|
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
@@ -837,6 +856,67 @@ function getRequestFeedbackPaths(req, parsed) {
|
|
|
837
856
|
});
|
|
838
857
|
}
|
|
839
858
|
|
|
859
|
+
const DATA_IDENTITY_CACHE = new Map();
|
|
860
|
+
const DATA_IDENTITY_CACHE_LIMIT = 2048;
|
|
861
|
+
|
|
862
|
+
function hashDataIdentity(prefix, value) {
|
|
863
|
+
const normalized = String(value || '');
|
|
864
|
+
const cacheKey = `${prefix}\0${normalized}`;
|
|
865
|
+
const cached = DATA_IDENTITY_CACHE.get(cacheKey);
|
|
866
|
+
if (cached) return cached;
|
|
867
|
+
|
|
868
|
+
// Billing identifiers can be email-like and API keys must never be made
|
|
869
|
+
// guessable through a fast digest. A memory-hard KDF gives us a stable,
|
|
870
|
+
// path-safe pseudonym without persisting the source identifier.
|
|
871
|
+
const derived = `${prefix}_${crypto.scryptSync(
|
|
872
|
+
normalized,
|
|
873
|
+
`thumbgate-${prefix}-data-identity-v1`,
|
|
874
|
+
16,
|
|
875
|
+
{ N: 16384, r: 8, p: 1 }
|
|
876
|
+
).toString('hex').slice(0, 24)}`;
|
|
877
|
+
|
|
878
|
+
if (DATA_IDENTITY_CACHE.size >= DATA_IDENTITY_CACHE_LIMIT) {
|
|
879
|
+
DATA_IDENTITY_CACHE.clear();
|
|
880
|
+
}
|
|
881
|
+
DATA_IDENTITY_CACHE.set(cacheKey, derived);
|
|
882
|
+
return derived;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function buildHostedTenantIdentity(validation = {}) {
|
|
886
|
+
if (!validation || validation.valid !== true || !validation.customerId) return null;
|
|
887
|
+
const tenantSeed = validation.customerId;
|
|
888
|
+
const principalSeed = validation.installId || tenantSeed;
|
|
889
|
+
const tenantId = hashDataIdentity('tenant', tenantSeed);
|
|
890
|
+
return {
|
|
891
|
+
partition: tenantId,
|
|
892
|
+
accessContext: {
|
|
893
|
+
tenantId,
|
|
894
|
+
principalId: hashDataIdentity('principal', principalSeed),
|
|
895
|
+
isAdmin: false,
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function resolveRequestDataIdentity(req, expectedApiKey) {
|
|
901
|
+
const token = extractApiKey(req);
|
|
902
|
+
if (!token || (expectedApiKey && safeKeyEqual(token, expectedApiKey))) {
|
|
903
|
+
return { partition: null, accessContext: null };
|
|
904
|
+
}
|
|
905
|
+
const validation = validateApiKey(token);
|
|
906
|
+
const identity = buildHostedTenantIdentity(validation);
|
|
907
|
+
if (validation.valid === true && !identity) {
|
|
908
|
+
return { partition: null, accessContext: null, invalidHostedIdentity: true };
|
|
909
|
+
}
|
|
910
|
+
return identity || { partition: null, accessContext: null };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function partitionFeedbackPaths(paths, dataIdentity) {
|
|
914
|
+
if (!dataIdentity?.partition) return paths;
|
|
915
|
+
return getFeedbackPaths({
|
|
916
|
+
feedbackDir: path.join(paths.FEEDBACK_DIR, 'tenants', dataIdentity.partition),
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
840
920
|
function getSafeDataDir(req, parsed) {
|
|
841
921
|
const { FEEDBACK_LOG_PATH } = getRequestFeedbackPaths(req, parsed);
|
|
842
922
|
return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
|
|
@@ -2501,8 +2581,9 @@ ${hiddenInputs}
|
|
|
2501
2581
|
<p class="email-note">Required so the checkout has attributable buyer intent and your receipt can be recovered.</p>
|
|
2502
2582
|
<button type="submit" class="primary">Pay $19/mo with Stripe →</button>
|
|
2503
2583
|
</form>
|
|
2584
|
+
<a class="secondary" data-i="pro_checkout_direct_stripe" rel="nofollow" href="/go/pro-direct?utm_source=website&utm_medium=checkout_interstitial&utm_campaign=pro_upgrade&utm_content=skip_form&cta_id=pro_checkout_direct_stripe&cta_placement=checkout_interstitial">Skip form — open Stripe checkout directly →</a>
|
|
2504
2585
|
<a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
|
|
2505
|
-
<p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
|
|
2586
|
+
<p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages. Email path keeps receipts attributable; skip uses a same-origin hop that bot-deflects crawlers.</p>
|
|
2506
2587
|
<div class="objection-box" aria-label="Checkout feedback">
|
|
2507
2588
|
<strong>Not buying today?</strong>
|
|
2508
2589
|
<p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
|
|
@@ -2825,6 +2906,37 @@ function serveTrackedLinkRedirect({ req, res, parsed, hostedConfig, isHeadReques
|
|
|
2825
2906
|
|
|
2826
2907
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
2827
2908
|
const journeyState = resolveJourneyState(req, parsed);
|
|
2909
|
+
const botClassification = classifyRequester(req.headers);
|
|
2910
|
+
if (target.botDeflectPath && botClassification.isBot) {
|
|
2911
|
+
const deflectUrl = new URL(target.botDeflectPath, hostedConfig.appOrigin);
|
|
2912
|
+
appendTrackedLinkQueryParams(deflectUrl, parsed, target);
|
|
2913
|
+
if (!isHeadRequest) {
|
|
2914
|
+
appendBestEffortTelemetry(
|
|
2915
|
+
FEEDBACK_DIR,
|
|
2916
|
+
{
|
|
2917
|
+
eventType: 'checkout_bot_deflected',
|
|
2918
|
+
clientType: 'web',
|
|
2919
|
+
page: `/go/${target.slug}`,
|
|
2920
|
+
reason: botClassification.reason || 'bot',
|
|
2921
|
+
destinationSlug: target.slug,
|
|
2922
|
+
acquisitionId: journeyState.acquisitionId,
|
|
2923
|
+
visitorId: journeyState.visitorId,
|
|
2924
|
+
sessionId: journeyState.sessionId,
|
|
2925
|
+
},
|
|
2926
|
+
req.headers,
|
|
2927
|
+
`tracked_link_bot_deflect:${target.slug}`
|
|
2928
|
+
);
|
|
2929
|
+
}
|
|
2930
|
+
res.writeHead(302, {
|
|
2931
|
+
...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
|
|
2932
|
+
'Cache-Control': 'no-store',
|
|
2933
|
+
'X-Robots-Tag': 'noindex,nofollow',
|
|
2934
|
+
'X-ThumbGate-Link-Slug': target.slug,
|
|
2935
|
+
Location: deflectUrl.toString(),
|
|
2936
|
+
});
|
|
2937
|
+
res.end();
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2828
2940
|
const destinationUrl = buildTrackedLinkDestination(target, hostedConfig, parsed);
|
|
2829
2941
|
const attribution = buildTrackedLinkAttribution(target, parsed, req, journeyState, destinationUrl);
|
|
2830
2942
|
if (target.external) {
|
|
@@ -5224,9 +5336,10 @@ function createApiServer() {
|
|
|
5224
5336
|
sendJson(res, 403, { error: 'project selection is only available on localhost requests' });
|
|
5225
5337
|
return;
|
|
5226
5338
|
}
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5339
|
+
let requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
5340
|
+
let requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
|
|
5341
|
+
let requestSafeDataDir = getSafeDataDir(req, parsed);
|
|
5342
|
+
let requestDocumentAccessContext = null;
|
|
5230
5343
|
|
|
5231
5344
|
// PostHog reverse proxy -- bypasses ad blockers.
|
|
5232
5345
|
// Only allow known PostHog API paths to prevent SSRF (CodeQL js/request-forgery).
|
|
@@ -8645,6 +8758,25 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8645
8758
|
return;
|
|
8646
8759
|
}
|
|
8647
8760
|
|
|
8761
|
+
// Provisioned hosted keys are authenticated identities, not merely valid
|
|
8762
|
+
// booleans. Bind every private data path to a hash of the billing customer
|
|
8763
|
+
// before any endpoint reads or writes feedback, documents, traces, or jobs.
|
|
8764
|
+
// Static admin/local operation retains the existing single-tenant path.
|
|
8765
|
+
const requestDataIdentity = resolveRequestDataIdentity(req, expectedApiKey);
|
|
8766
|
+
if (requestDataIdentity.invalidHostedIdentity) {
|
|
8767
|
+
sendProblem(res, {
|
|
8768
|
+
type: PROBLEM_TYPES.UNAUTHORIZED,
|
|
8769
|
+
title: 'Unauthorized',
|
|
8770
|
+
status: 403,
|
|
8771
|
+
detail: 'The hosted API key is not bound to a customer identity.',
|
|
8772
|
+
});
|
|
8773
|
+
return;
|
|
8774
|
+
}
|
|
8775
|
+
requestFeedbackPaths = partitionFeedbackPaths(requestFeedbackPaths, requestDataIdentity);
|
|
8776
|
+
requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
|
|
8777
|
+
requestSafeDataDir = path.resolve(requestFeedbackDir);
|
|
8778
|
+
requestDocumentAccessContext = requestDataIdentity.accessContext;
|
|
8779
|
+
|
|
8648
8780
|
// Usage metering — record request for billing keys (not static THUMBGATE_API_KEY)
|
|
8649
8781
|
const _token = extractBearerToken(req);
|
|
8650
8782
|
if (_token && _token !== expectedApiKey) {
|
|
@@ -8665,6 +8797,33 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8665
8797
|
stats.tier = 'Pro';
|
|
8666
8798
|
}
|
|
8667
8799
|
|
|
8800
|
+
// Active gates for the dashboard hero cards. Connect only fetches
|
|
8801
|
+
// /v1/feedback/stats first; without these fields Active Gates stays
|
|
8802
|
+
// stuck on the HTML placeholder "—" until a later /v1/dashboard call
|
|
8803
|
+
// succeeds (and never updates if that call fails).
|
|
8804
|
+
try {
|
|
8805
|
+
const { computeGateStats } = require('../../scripts/dashboard');
|
|
8806
|
+
const gateStats = computeGateStats();
|
|
8807
|
+
stats.activeGateCount = Number(gateStats.totalGates) || 0;
|
|
8808
|
+
stats.gateStats = {
|
|
8809
|
+
totalGates: Number(gateStats.totalGates) || 0,
|
|
8810
|
+
manualCount: Number(gateStats.manualCount) || 0,
|
|
8811
|
+
autoCount: Number(gateStats.autoCount) || 0,
|
|
8812
|
+
blocked: Number(gateStats.blocked) || 0,
|
|
8813
|
+
warned: Number(gateStats.warned) || 0,
|
|
8814
|
+
};
|
|
8815
|
+
} catch (error) {
|
|
8816
|
+
debugApiFallback('gate stats unavailable for feedback/stats', error);
|
|
8817
|
+
stats.activeGateCount = 0;
|
|
8818
|
+
stats.gateStats = {
|
|
8819
|
+
totalGates: 0,
|
|
8820
|
+
manualCount: 0,
|
|
8821
|
+
autoCount: 0,
|
|
8822
|
+
blocked: 0,
|
|
8823
|
+
warned: 0,
|
|
8824
|
+
};
|
|
8825
|
+
}
|
|
8826
|
+
|
|
8668
8827
|
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
8669
8828
|
|
|
8670
8829
|
stats.geminiConfigured = Boolean(
|
|
@@ -9364,13 +9523,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9364
9523
|
const signal = parsed.searchParams.get('signal') || null;
|
|
9365
9524
|
let results;
|
|
9366
9525
|
try {
|
|
9367
|
-
|
|
9368
|
-
results = searchThumbgate({
|
|
9526
|
+
results = await searchThumbgateAsync({
|
|
9369
9527
|
query,
|
|
9370
9528
|
limit: Number.isFinite(limit) ? limit : 10,
|
|
9371
9529
|
source,
|
|
9372
9530
|
signal,
|
|
9373
|
-
feedbackDir:
|
|
9531
|
+
feedbackDir: requestFeedbackDir,
|
|
9532
|
+
metadataFilters: {
|
|
9533
|
+
tags: (parsed.searchParams.get('tags') || '').split(',').map((tag) => tag.trim()).filter(Boolean),
|
|
9534
|
+
sourceFormat: parsed.searchParams.get('sourceFormat') || undefined,
|
|
9535
|
+
sourceType: parsed.searchParams.get('sourceType') || undefined,
|
|
9536
|
+
},
|
|
9537
|
+
queryRewrite: parsed.searchParams.get('queryRewrite') !== 'false',
|
|
9538
|
+
accessContext: requestDocumentAccessContext,
|
|
9374
9539
|
});
|
|
9375
9540
|
} catch (err) {
|
|
9376
9541
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -9388,13 +9553,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9388
9553
|
const body = await parseJsonBody(req);
|
|
9389
9554
|
let results;
|
|
9390
9555
|
try {
|
|
9391
|
-
|
|
9392
|
-
results = searchThumbgate({
|
|
9556
|
+
results = await searchThumbgateAsync({
|
|
9393
9557
|
query: body.query || body.q || '',
|
|
9394
9558
|
limit: body.limit,
|
|
9395
9559
|
source: body.source,
|
|
9396
9560
|
signal: body.signal,
|
|
9397
|
-
feedbackDir:
|
|
9561
|
+
feedbackDir: requestFeedbackDir,
|
|
9562
|
+
metadataFilters: body.filters,
|
|
9563
|
+
queryRewrite: body.queryRewrite !== false,
|
|
9564
|
+
accessContext: requestDocumentAccessContext,
|
|
9398
9565
|
});
|
|
9399
9566
|
} catch (err) {
|
|
9400
9567
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -9412,6 +9579,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9412
9579
|
limit: Number.isFinite(limit) ? limit : 20,
|
|
9413
9580
|
query,
|
|
9414
9581
|
tag,
|
|
9582
|
+
accessContext: requestDocumentAccessContext,
|
|
9415
9583
|
});
|
|
9416
9584
|
sendJson(res, 200, results);
|
|
9417
9585
|
return;
|
|
@@ -9425,6 +9593,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9425
9593
|
}
|
|
9426
9594
|
const document = readImportedDocument(documentId, {
|
|
9427
9595
|
feedbackDir: requestFeedbackDir,
|
|
9596
|
+
accessContext: requestDocumentAccessContext,
|
|
9428
9597
|
});
|
|
9429
9598
|
if (!document) {
|
|
9430
9599
|
throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
|
|
@@ -9557,6 +9726,11 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9557
9726
|
tags: extractTags(body.tags),
|
|
9558
9727
|
proposeGates: body.proposeGates !== false,
|
|
9559
9728
|
feedbackDir: requestFeedbackDir,
|
|
9729
|
+
accessContext: requestDocumentAccessContext,
|
|
9730
|
+
visibility: body.visibility === 'private' ? 'private' : 'tenant',
|
|
9731
|
+
allowedPrincipals: Array.isArray(body.allowedPrincipals)
|
|
9732
|
+
? body.allowedPrincipals.map((value) => String(value || '').trim()).filter(Boolean)
|
|
9733
|
+
: [],
|
|
9560
9734
|
});
|
|
9561
9735
|
sendJson(res, 201, {
|
|
9562
9736
|
ok: true,
|
|
@@ -9607,14 +9781,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9607
9781
|
fs.writeFileSync(paths.outputPath, result.jsonl);
|
|
9608
9782
|
}
|
|
9609
9783
|
|
|
9610
|
-
|
|
9784
|
+
const response = {
|
|
9611
9785
|
pairs: result.pairs.length,
|
|
9786
|
+
pairCount: result.pairs.length,
|
|
9612
9787
|
errors: result.errors.length,
|
|
9613
9788
|
learnings: result.learnings.length,
|
|
9614
9789
|
unpairedErrors: result.unpairedErrors.length,
|
|
9615
9790
|
unpairedLearnings: result.unpairedLearnings.length,
|
|
9616
9791
|
outputPath: paths.outputPath,
|
|
9617
|
-
}
|
|
9792
|
+
};
|
|
9793
|
+
if (body.includePairs === true) {
|
|
9794
|
+
response.records = result.pairs;
|
|
9795
|
+
}
|
|
9796
|
+
sendJson(res, 200, response);
|
|
9618
9797
|
return;
|
|
9619
9798
|
}
|
|
9620
9799
|
|
|
@@ -10640,6 +10819,8 @@ module.exports = {
|
|
|
10640
10819
|
buildLossAnalyticsResponse,
|
|
10641
10820
|
buildIntakeAlertRateLimitKey,
|
|
10642
10821
|
sanitizeHtmlUnsafeJsonValue,
|
|
10822
|
+
buildHostedTenantIdentity,
|
|
10823
|
+
partitionFeedbackPaths,
|
|
10643
10824
|
},
|
|
10644
10825
|
};
|
|
10645
10826
|
|