thumbgate 1.27.6 → 1.27.8
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/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +6 -2
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +49 -5
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +16 -1
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bin/cli.js +180 -2
- package/bin/postinstall.js +1 -1
- package/config/gate-templates.json +84 -0
- package/config/gates/claim-verification.json +6 -0
- package/config/gates/default.json +20 -0
- package/config/github-about.json +1 -1
- package/config/model-candidates.json +50 -0
- package/package.json +66 -25
- package/public/agent-manager.html +41 -1
- package/public/agents-cost-savings.html +1 -1
- package/public/ai-malpractice-prevention.html +2 -1
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +15 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +1 -1
- package/public/brand/thumbgate-mark.svg +15 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/codex-enterprise.html +1 -1
- package/public/codex-plugin.html +1 -1
- package/public/compare.html +23 -3
- package/public/dashboard.html +312 -30
- package/public/federal.html +1 -1
- package/public/guide.html +5 -4
- package/public/index.html +167 -49
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +74 -7
- package/public/lessons.html +2 -1
- package/public/numbers.html +3 -3
- package/public/pricing.html +63 -15
- package/public/pro.html +7 -7
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/auto-promote-gates.js +75 -4
- package/scripts/build-metadata.js +24 -3
- package/scripts/cli-schema.js +22 -0
- package/scripts/dashboard-chat.js +2 -1
- package/scripts/dashboard.js +8 -0
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-loop.js +34 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gates-engine.js +198 -6
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-stop-anti-claim.js +227 -0
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/operational-integrity.js +39 -5
- package/scripts/plausible-domain-config.js +4 -2
- package/scripts/rate-limiter.js +12 -6
- package/scripts/secret-redaction.js +166 -0
- package/scripts/security-scanner.js +100 -0
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/seo-gsd.js +635 -0
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +5 -2
- package/scripts/statusline.sh +13 -1
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/telemetry-analytics.js +9 -0
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/vector-store.js +44 -0
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +715 -86
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const path = require('path');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const {
|
|
7
|
+
getAggregateStatuslineCachePath,
|
|
8
|
+
shouldAggregateFeedback,
|
|
9
|
+
} = require('./feedback-aggregate');
|
|
5
10
|
const {
|
|
6
11
|
listFeedbackArtifactPaths,
|
|
7
12
|
resolveFeedbackDir,
|
|
@@ -12,18 +17,28 @@ function unique(values = []) {
|
|
|
12
17
|
return [...new Set(values.filter(Boolean).map((value) => path.resolve(value)))];
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
function hasIsolatedTempFeedbackDir(env = process.env) {
|
|
21
|
+
if (!env.THUMBGATE_FEEDBACK_DIR) return false;
|
|
22
|
+
try {
|
|
23
|
+
return path.resolve(env.THUMBGATE_FEEDBACK_DIR).startsWith(path.resolve(os.tmpdir()) + path.sep);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
function getStatuslineCacheCandidates(options = {}) {
|
|
16
30
|
const env = options.env || process.env;
|
|
17
31
|
const projectDir = resolveProjectDir({ cwd: options.cwd, env });
|
|
18
32
|
const feedbackDir = resolveFeedbackDir({ projectDir, env });
|
|
19
33
|
|
|
20
34
|
return unique([
|
|
35
|
+
shouldAggregateFeedback({ env }) && !hasIsolatedTempFeedbackDir(env) && getAggregateStatuslineCachePath({ env }),
|
|
21
36
|
...listFeedbackArtifactPaths('statusline_cache.json', { projectDir, env }),
|
|
22
37
|
path.join(feedbackDir, 'statusline_cache.json'),
|
|
23
38
|
]);
|
|
24
39
|
}
|
|
25
40
|
|
|
26
|
-
if (
|
|
41
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
|
27
42
|
process.stdout.write(JSON.stringify({ candidates: getStatuslineCacheCandidates() }));
|
|
28
43
|
}
|
|
29
44
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Resolve the statusline cache to read for display.
|
|
5
|
+
//
|
|
6
|
+
// PRIOR BUG: the earlier version of this script "aggregated" by summing the
|
|
7
|
+
// thumbs_up/down fields across every per-folder statusline_cache.json. That
|
|
8
|
+
// double-counted, because the global aggregate cache at
|
|
9
|
+
// ~/.thumbgate/statusline_cache.json is ITSELF already the cross-store sum
|
|
10
|
+
// (written by feedback-aggregate.js / hook-thumbgate-cache-updater.js). Summing
|
|
11
|
+
// the global aggregate plus per-folder caches counted every event twice or more
|
|
12
|
+
// and produced bogus totals like 1152↑/747↓ when the true aggregate was 727/600.
|
|
13
|
+
//
|
|
14
|
+
// CORRECT BEHAVIOR: pick the highest-priority existing cache from the
|
|
15
|
+
// candidate list (`statusline-cache-path.js` puts the canonical aggregate path
|
|
16
|
+
// first when aggregation is enabled) and return its content unchanged. No
|
|
17
|
+
// summing across files — the upstream aggregator already did that work.
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { getStatuslineCacheCandidates } = require('./statusline-cache-path');
|
|
22
|
+
|
|
23
|
+
function readCacheFile(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
28
|
+
} catch {
|
|
29
|
+
/* unreadable / unparseable caches are silently skipped */
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readResolvedStatuslineCache(options = {}) {
|
|
35
|
+
const candidates = getStatuslineCacheCandidates(options);
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
if (!fs.existsSync(candidate)) continue;
|
|
38
|
+
const data = readCacheFile(candidate);
|
|
39
|
+
if (data) {
|
|
40
|
+
return { ...data, source: path.resolve(candidate) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const _invokedDirectly =
|
|
47
|
+
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
|
|
48
|
+
if (_invokedDirectly) {
|
|
49
|
+
const resolved = readResolvedStatuslineCache();
|
|
50
|
+
if (resolved) {
|
|
51
|
+
process.stdout.write(JSON.stringify(resolved));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
readResolvedStatuslineCache,
|
|
57
|
+
};
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { analyzeFeedback } = require('./feedback-loop');
|
|
5
|
+
const {
|
|
6
|
+
computeAggregateFeedbackStats,
|
|
7
|
+
shouldAggregateFeedback,
|
|
8
|
+
} = require('./feedback-aggregate');
|
|
5
9
|
const { normalizeStatsPayload } = require('./hook-thumbgate-cache-updater');
|
|
6
10
|
const { syncClaudeHistoryFeedback } = require('./claude-feedback-sync');
|
|
7
11
|
const { resolveProjectDir } = require('./feedback-paths');
|
|
@@ -9,9 +13,13 @@ const { resolveProjectDir } = require('./feedback-paths');
|
|
|
9
13
|
try {
|
|
10
14
|
const projectDir = resolveProjectDir({ cwd: process.cwd(), env: process.env });
|
|
11
15
|
syncClaudeHistoryFeedback({ projectDir });
|
|
12
|
-
const
|
|
16
|
+
const scope = String(process.env.THUMBGATE_STATUSLINE_SCOPE || 'global').toLowerCase();
|
|
17
|
+
const stats = scope !== 'project' && shouldAggregateFeedback({ env: process.env })
|
|
18
|
+
? computeAggregateFeedbackStats({ projectDir, env: process.env })
|
|
19
|
+
: analyzeFeedback();
|
|
13
20
|
const payload = {
|
|
14
21
|
...normalizeStatsPayload(stats),
|
|
22
|
+
aggregate: stats.aggregate || { enabled: false },
|
|
15
23
|
updated_at: String(Math.floor(Date.now() / 1000)),
|
|
16
24
|
};
|
|
17
25
|
process.stdout.write(JSON.stringify(payload));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { isProLicensed } = require('./license');
|
|
5
|
+
const { isProLicensed, isValidKey } = require('./license');
|
|
6
6
|
|
|
7
7
|
function getStatuslineMeta(options = {}) {
|
|
8
8
|
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
@@ -11,7 +11,10 @@ function getStatuslineMeta(options = {}) {
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
|
|
13
13
|
// Enterprise detection based on key prefix
|
|
14
|
-
let apiKey = env.
|
|
14
|
+
let apiKey = env.THUMBGATE_OPERATOR_KEY || env.THUMBGATE_API_KEY || '';
|
|
15
|
+
if (apiKey && !isValidKey(apiKey)) {
|
|
16
|
+
apiKey = '';
|
|
17
|
+
}
|
|
15
18
|
|
|
16
19
|
// Fallback to reading from disk if not in env
|
|
17
20
|
if (!apiKey) {
|
package/scripts/statusline.sh
CHANGED
|
@@ -45,7 +45,19 @@ if [ -z "$THUMBGATE_CACHE" ]; then
|
|
|
45
45
|
fi
|
|
46
46
|
|
|
47
47
|
UP="0"; DOWN="0"; LESSONS="0"; TREND="?"; CACHE_TS="0"
|
|
48
|
-
|
|
48
|
+
# Display reads aggregate across every per-folder cache so the statusline
|
|
49
|
+
# reflects true totals, not whichever folder happens to be cwd. Writes still
|
|
50
|
+
# target $THUMBGATE_CACHE (per-folder), so attribution is preserved.
|
|
51
|
+
_RESOLVED_CACHE_JSON=$(node "${SCRIPT_DIR}/statusline-cache-read.js" 2>/dev/null)
|
|
52
|
+
if [[ -n "$_RESOLVED_CACHE_JSON" ]]; then
|
|
53
|
+
eval "$(echo "$_RESOLVED_CACHE_JSON" | jq -r '
|
|
54
|
+
@sh "UP=\(.thumbs_up // "0")",
|
|
55
|
+
@sh "DOWN=\(.thumbs_down // "0")",
|
|
56
|
+
@sh "LESSONS=\(.lessons // "0")",
|
|
57
|
+
@sh "TREND=\(.trend // "?")",
|
|
58
|
+
@sh "CACHE_TS=\(.updated_at // "0")"
|
|
59
|
+
' 2>/dev/null)"
|
|
60
|
+
elif [[ -f "$THUMBGATE_CACHE" ]]; then
|
|
49
61
|
eval "$(jq -r '
|
|
50
62
|
@sh "UP=\(.thumbs_up // "0")",
|
|
51
63
|
@sh "DOWN=\(.thumbs_down // "0")",
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* sync-telemetry-from-prod.js
|
|
6
|
+
*
|
|
7
|
+
* The agentic data pipeline (scripts/agentic-data-pipeline.js →
|
|
8
|
+
* scripts/telemetry-analytics.js) computes `get_business_metrics` from the LOCAL
|
|
9
|
+
* telemetry store at `<feedbackDir>/telemetry-pings.jsonl`. On a developer
|
|
10
|
+
* machine that store is near-empty, so metrics read uniqueVisitors:0 /
|
|
11
|
+
* checkoutStarts:0 even though real web traffic is flowing.
|
|
12
|
+
*
|
|
13
|
+
* The real web funnel lives on the PROD Railway volume and is exposed only via
|
|
14
|
+
* the operator-key-gated endpoint `GET /v1/telemetry/export`. Both stores use the
|
|
15
|
+
* SAME jsonl format/filename and matching fields (receivedAt/event/eventType/
|
|
16
|
+
* installId/visitorId/sessionId/traceId), so a sync is a clean append + dedupe.
|
|
17
|
+
*
|
|
18
|
+
* This script:
|
|
19
|
+
* 1. Resolves the operator (or admin) key from env or ~/.config/thumbgate/operator.json.
|
|
20
|
+
* 2. GETs /v1/telemetry/export?source=both with `Authorization: Bearer <key>`.
|
|
21
|
+
* 3. Merges/dedupes the returned `telemetry` rows into
|
|
22
|
+
* `<feedbackDir>/telemetry-pings.jsonl` (dedupe by a stable sha256 of the
|
|
23
|
+
* canonical row, so repeat runs never double-count).
|
|
24
|
+
* 4. Optionally (`--funnel`) persists the returned `funnel` rows into
|
|
25
|
+
* `<feedbackDir>/funnel-events.jsonl` the same way.
|
|
26
|
+
*
|
|
27
|
+
* After a run, `get_business_metrics` reflects the real funnel because the
|
|
28
|
+
* pipeline reads the same feedback dir on each call.
|
|
29
|
+
*
|
|
30
|
+
* SECURITY: the operator key is never printed. Only its source is reported.
|
|
31
|
+
*
|
|
32
|
+
* Usage:
|
|
33
|
+
* node scripts/sync-telemetry-from-prod.js [--since-days=30] [--since=<iso>]
|
|
34
|
+
* [--limit=10000] [--source=both|telemetry|funnel] [--funnel]
|
|
35
|
+
* [--base-url=<url>] [--feedback-dir=<dir>] [--json] [--dry-run]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const os = require('node:os');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
const crypto = require('node:crypto');
|
|
42
|
+
|
|
43
|
+
const { getFeedbackPaths } = require('./feedback-loop');
|
|
44
|
+
|
|
45
|
+
const DEFAULT_BASE_URL = 'https://thumbgate-production.up.railway.app';
|
|
46
|
+
const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
|
|
47
|
+
const TELEMETRY_FILE_NAME = 'telemetry-pings.jsonl';
|
|
48
|
+
const FUNNEL_FILE_NAME = 'funnel-events.jsonl';
|
|
49
|
+
const EXPORT_PATH = '/v1/telemetry/export';
|
|
50
|
+
const HARD_LIMIT = 10000; // server clamps each stream to this
|
|
51
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
// Configure fetch proxy when running behind a corporate/sandbox proxy. Mirrors
|
|
54
|
+
// scripts/operational-summary.js so hosted reads behave the same everywhere.
|
|
55
|
+
(function configureProxy() {
|
|
56
|
+
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy
|
|
57
|
+
|| process.env.HTTP_PROXY || process.env.http_proxy;
|
|
58
|
+
if (!proxyUrl) return;
|
|
59
|
+
try {
|
|
60
|
+
const { ProxyAgent, setGlobalDispatcher } = require('undici');
|
|
61
|
+
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
62
|
+
} catch {
|
|
63
|
+
// undici not available — fetch will use the default dispatcher.
|
|
64
|
+
}
|
|
65
|
+
}());
|
|
66
|
+
|
|
67
|
+
function normalizeText(value) {
|
|
68
|
+
if (value === undefined || value === null) return null;
|
|
69
|
+
const text = String(value).trim();
|
|
70
|
+
return text || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadOperatorConfig(configPath = OPERATOR_CONFIG_PATH) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
76
|
+
return {
|
|
77
|
+
operatorKey: normalizeText(parsed.operatorKey),
|
|
78
|
+
baseUrl: normalizeText(parsed.baseUrl),
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return { operatorKey: null, baseUrl: null };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the credential + base URL the same way scripts/operational-summary.js
|
|
87
|
+
* does: env THUMBGATE_OPERATOR_KEY > operator.json operatorKey > env
|
|
88
|
+
* THUMBGATE_API_KEY (admin keys are an accepted alternate auth path on the
|
|
89
|
+
* export endpoint). `source` records WHERE the key came from for diagnostics —
|
|
90
|
+
* the key value itself is never returned to callers that print it.
|
|
91
|
+
*/
|
|
92
|
+
function resolveOperatorConfig(options = {}) {
|
|
93
|
+
const operatorConfig = loadOperatorConfig(options.configPath);
|
|
94
|
+
let apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY);
|
|
95
|
+
let keySource = apiKey ? 'env:THUMBGATE_OPERATOR_KEY' : null;
|
|
96
|
+
if (!apiKey && operatorConfig.operatorKey) {
|
|
97
|
+
apiKey = operatorConfig.operatorKey;
|
|
98
|
+
keySource = 'operator.json';
|
|
99
|
+
}
|
|
100
|
+
if (!apiKey) {
|
|
101
|
+
apiKey = normalizeText(process.env.THUMBGATE_API_KEY);
|
|
102
|
+
keySource = apiKey ? 'env:THUMBGATE_API_KEY' : null;
|
|
103
|
+
}
|
|
104
|
+
const baseUrl = normalizeText(options.baseUrl)
|
|
105
|
+
|| normalizeText(process.env.THUMBGATE_SYNC_BASE_URL)
|
|
106
|
+
|| normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
|
|
107
|
+
|| operatorConfig.baseUrl
|
|
108
|
+
|| DEFAULT_BASE_URL;
|
|
109
|
+
return { apiKey, keySource, baseUrl };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Deterministic, LOCALE-INDEPENDENT key comparator (UTF-16 code-unit order).
|
|
113
|
+
// Do NOT switch this to String.prototype.localeCompare (what SonarCloud S2871
|
|
114
|
+
// suggests): localeCompare is locale-sensitive, so it would make the canonical
|
|
115
|
+
// serialization — and therefore the dedupe hash below — differ across machines
|
|
116
|
+
// and CI runners, silently breaking cross-environment dedupe.
|
|
117
|
+
function compareKeys(a, b) {
|
|
118
|
+
if (a < b) return -1;
|
|
119
|
+
if (a > b) return 1;
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Recursively sort object keys so structurally-identical rows serialize
|
|
124
|
+
// identically regardless of original key order.
|
|
125
|
+
function canonicalize(value) {
|
|
126
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
127
|
+
if (value && typeof value === 'object') {
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const key of Object.keys(value).sort(compareKeys)) out[key] = canonicalize(value[key]);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Stable dedupe id. sha256 (not sha1/md5 — SonarCloud S4790) over the canonical
|
|
136
|
+
// JSON of the whole row: identical rows → identical id across runs.
|
|
137
|
+
function stableRowId(row) {
|
|
138
|
+
return crypto.createHash('sha256').update(JSON.stringify(canonicalize(row))).digest('hex');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The export endpoint returns each stream as { rows: [...] }; older/simplified
|
|
142
|
+
// shapes may return a bare array. Accept both.
|
|
143
|
+
function extractRows(streamValue) {
|
|
144
|
+
if (Array.isArray(streamValue)) return streamValue;
|
|
145
|
+
if (streamValue && Array.isArray(streamValue.rows)) return streamValue.rows;
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetch the telemetry export. `fetchImpl` is injectable so tests run without
|
|
151
|
+
* network or secrets. Auth is `Authorization: Bearer <key>` — the server's
|
|
152
|
+
* extractApiKey reads Bearer / x-api-key, NOT x-operator-key.
|
|
153
|
+
*/
|
|
154
|
+
async function fetchTelemetryExport(params = {}) {
|
|
155
|
+
const {
|
|
156
|
+
baseUrl,
|
|
157
|
+
operatorKey,
|
|
158
|
+
since,
|
|
159
|
+
limit = HARD_LIMIT,
|
|
160
|
+
source = 'both',
|
|
161
|
+
fetchImpl = globalThis.fetch,
|
|
162
|
+
} = params;
|
|
163
|
+
if (!operatorKey) {
|
|
164
|
+
const err = new Error('No operator/admin key available for telemetry export.');
|
|
165
|
+
err.code = 'no_key';
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
if (typeof fetchImpl !== 'function') {
|
|
169
|
+
const err = new Error('No fetch implementation available (Node >=18 or inject fetchImpl).');
|
|
170
|
+
err.code = 'no_fetch';
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
const url = new URL(EXPORT_PATH, baseUrl || DEFAULT_BASE_URL);
|
|
174
|
+
url.searchParams.set('source', source);
|
|
175
|
+
url.searchParams.set('limit', String(Math.min(Math.max(1, Number(limit) || HARD_LIMIT), HARD_LIMIT)));
|
|
176
|
+
if (since) url.searchParams.set('since', since);
|
|
177
|
+
|
|
178
|
+
const response = await fetchImpl(url, {
|
|
179
|
+
method: 'GET',
|
|
180
|
+
headers: {
|
|
181
|
+
authorization: `Bearer ${operatorKey}`,
|
|
182
|
+
accept: 'application/json',
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const bodyText = await response.text().catch(() => '');
|
|
187
|
+
const err = new Error(`Telemetry export failed: HTTP ${response.status} ${bodyText.slice(0, 200)}`);
|
|
188
|
+
err.code = 'http_error';
|
|
189
|
+
err.status = response.status;
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
return response.json();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read every stable id already present in a jsonl ledger (skips unparseable lines).
|
|
196
|
+
function readExistingIds(filePath) {
|
|
197
|
+
const ids = new Set();
|
|
198
|
+
if (!fs.existsSync(filePath)) return ids;
|
|
199
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
200
|
+
for (const line of text.split('\n')) {
|
|
201
|
+
if (!line.trim()) continue;
|
|
202
|
+
try {
|
|
203
|
+
ids.add(stableRowId(JSON.parse(line)));
|
|
204
|
+
} catch {
|
|
205
|
+
// Tolerate malformed historical lines.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return ids;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Append only rows whose stable id isn't already present. Pure aside from the
|
|
213
|
+
* single append; safe to re-run. Returns { added, skipped }.
|
|
214
|
+
*/
|
|
215
|
+
function mergeRowsIntoLedger(filePath, rows, options = {}) {
|
|
216
|
+
const existing = options.existingIds || readExistingIds(filePath);
|
|
217
|
+
const toAppend = [];
|
|
218
|
+
let skipped = 0;
|
|
219
|
+
for (const row of rows) {
|
|
220
|
+
if (!row || typeof row !== 'object') { skipped += 1; continue; }
|
|
221
|
+
const id = stableRowId(row);
|
|
222
|
+
if (existing.has(id)) { skipped += 1; continue; }
|
|
223
|
+
existing.add(id);
|
|
224
|
+
toAppend.push(JSON.stringify(row));
|
|
225
|
+
}
|
|
226
|
+
if (toAppend.length && !options.dryRun) {
|
|
227
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
228
|
+
fs.appendFileSync(filePath, toAppend.map((l) => `${l}\n`).join(''), 'utf8');
|
|
229
|
+
}
|
|
230
|
+
return { added: toAppend.length, skipped };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resolveFeedbackDir(options = {}) {
|
|
234
|
+
if (options.feedbackDir) return path.resolve(options.feedbackDir);
|
|
235
|
+
return getFeedbackPaths().FEEDBACK_DIR;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Resolve the `since` ISO timestamp: an explicit --since wins; otherwise derive
|
|
239
|
+
// it from --since-days relative to nowMs (test-injectable) or the current time.
|
|
240
|
+
function computeSinceIso(options = {}) {
|
|
241
|
+
if (options.since) return options.since;
|
|
242
|
+
if (!Number.isFinite(options.sinceDays)) return undefined;
|
|
243
|
+
const baseMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
244
|
+
return new Date(baseMs - options.sinceDays * MS_PER_DAY).toISOString();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* End-to-end sync: fetch export → merge telemetry (always) and funnel (opt-in).
|
|
249
|
+
* Returns a summary object with counts and resolved paths (no secrets).
|
|
250
|
+
*/
|
|
251
|
+
async function syncTelemetryFromProd(options = {}) {
|
|
252
|
+
const config = resolveOperatorConfig(options);
|
|
253
|
+
const feedbackDir = resolveFeedbackDir(options);
|
|
254
|
+
const source = options.source || (options.funnel ? 'both' : 'telemetry');
|
|
255
|
+
const sinceIso = computeSinceIso(options);
|
|
256
|
+
|
|
257
|
+
const payload = await fetchTelemetryExport({
|
|
258
|
+
baseUrl: config.baseUrl,
|
|
259
|
+
operatorKey: config.apiKey,
|
|
260
|
+
since: sinceIso,
|
|
261
|
+
limit: options.limit || HARD_LIMIT,
|
|
262
|
+
source,
|
|
263
|
+
fetchImpl: options.fetchImpl,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const telemetryRows = extractRows(payload.telemetry);
|
|
267
|
+
const telemetryPath = path.join(feedbackDir, TELEMETRY_FILE_NAME);
|
|
268
|
+
const telemetryResult = mergeRowsIntoLedger(telemetryPath, telemetryRows, { dryRun: options.dryRun });
|
|
269
|
+
|
|
270
|
+
const summary = {
|
|
271
|
+
feedbackDir,
|
|
272
|
+
baseUrl: config.baseUrl,
|
|
273
|
+
keySource: config.keySource,
|
|
274
|
+
source,
|
|
275
|
+
since: payload.since || sinceIso || null,
|
|
276
|
+
dryRun: !!options.dryRun,
|
|
277
|
+
telemetry: {
|
|
278
|
+
path: telemetryPath,
|
|
279
|
+
fetched: telemetryRows.length,
|
|
280
|
+
added: telemetryResult.added,
|
|
281
|
+
skipped: telemetryResult.skipped,
|
|
282
|
+
},
|
|
283
|
+
funnel: null,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (options.funnel) {
|
|
287
|
+
const funnelRows = extractRows(payload.funnel);
|
|
288
|
+
const funnelPath = path.join(feedbackDir, FUNNEL_FILE_NAME);
|
|
289
|
+
const funnelResult = mergeRowsIntoLedger(funnelPath, funnelRows, { dryRun: options.dryRun });
|
|
290
|
+
summary.funnel = {
|
|
291
|
+
path: funnelPath,
|
|
292
|
+
fetched: funnelRows.length,
|
|
293
|
+
added: funnelResult.added,
|
|
294
|
+
skipped: funnelResult.skipped,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return summary;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseArgs(argv) {
|
|
302
|
+
const opts = { sinceDays: 30, funnel: false, json: false, dryRun: false };
|
|
303
|
+
for (const arg of argv) {
|
|
304
|
+
if (arg === '--funnel') opts.funnel = true;
|
|
305
|
+
else if (arg === '--json') opts.json = true;
|
|
306
|
+
else if (arg === '--dry-run') opts.dryRun = true;
|
|
307
|
+
else if (arg.startsWith('--since-days=')) opts.sinceDays = Number(arg.split('=')[1]);
|
|
308
|
+
else if (arg.startsWith('--since=')) opts.since = arg.split('=').slice(1).join('=');
|
|
309
|
+
else if (arg.startsWith('--limit=')) opts.limit = Number(arg.split('=')[1]);
|
|
310
|
+
else if (arg.startsWith('--source=')) opts.source = arg.split('=')[1];
|
|
311
|
+
else if (arg.startsWith('--base-url=')) opts.baseUrl = arg.split('=').slice(1).join('=');
|
|
312
|
+
else if (arg.startsWith('--feedback-dir=')) opts.feedbackDir = arg.split('=').slice(1).join('=');
|
|
313
|
+
}
|
|
314
|
+
if (!Number.isFinite(opts.sinceDays)) opts.sinceDays = 30;
|
|
315
|
+
return opts;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function main() {
|
|
319
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
320
|
+
const preflight = resolveOperatorConfig(opts);
|
|
321
|
+
if (!preflight.apiKey) {
|
|
322
|
+
console.error(
|
|
323
|
+
'No operator/admin key found. Set THUMBGATE_OPERATOR_KEY (or THUMBGATE_API_KEY), '
|
|
324
|
+
+ 'or add operatorKey to ~/.config/thumbgate/operator.json. '
|
|
325
|
+
+ 'The key lives in Railway prod env as THUMBGATE_OPERATOR_KEY.'
|
|
326
|
+
);
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const summary = await syncTelemetryFromProd(opts);
|
|
332
|
+
if (opts.json) {
|
|
333
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
334
|
+
} else {
|
|
335
|
+
const t = summary.telemetry;
|
|
336
|
+
console.log(`✅ Telemetry sync${summary.dryRun ? ' (dry-run)' : ''} complete`);
|
|
337
|
+
console.log(` source=${summary.source} since=${summary.since || 'server-default'} via ${summary.keySource}`);
|
|
338
|
+
console.log(` telemetry: ${t.fetched} fetched → ${t.added} added, ${t.skipped} already present`);
|
|
339
|
+
console.log(` → ${t.path}`);
|
|
340
|
+
if (summary.funnel) {
|
|
341
|
+
const f = summary.funnel;
|
|
342
|
+
console.log(` funnel: ${f.fetched} fetched → ${f.added} added, ${f.skipped} already present`);
|
|
343
|
+
console.log(` → ${f.path}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.error(`❌ Telemetry sync failed: ${err.message}`);
|
|
348
|
+
process.exitCode = 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Path-based entrypoint check (require.main === module is flagged by SonarCloud S3403).
|
|
353
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
|
354
|
+
main();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
DEFAULT_BASE_URL,
|
|
359
|
+
TELEMETRY_FILE_NAME,
|
|
360
|
+
FUNNEL_FILE_NAME,
|
|
361
|
+
HARD_LIMIT,
|
|
362
|
+
canonicalize,
|
|
363
|
+
stableRowId,
|
|
364
|
+
extractRows,
|
|
365
|
+
loadOperatorConfig,
|
|
366
|
+
resolveOperatorConfig,
|
|
367
|
+
fetchTelemetryExport,
|
|
368
|
+
readExistingIds,
|
|
369
|
+
mergeRowsIntoLedger,
|
|
370
|
+
resolveFeedbackDir,
|
|
371
|
+
computeSinceIso,
|
|
372
|
+
syncTelemetryFromProd,
|
|
373
|
+
parseArgs,
|
|
374
|
+
};
|
|
@@ -75,6 +75,13 @@ function normalizeInteger(value) {
|
|
|
75
75
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function normalizeRate(value) {
|
|
79
|
+
if (value === undefined || value === null || value === '') return null;
|
|
80
|
+
const parsed = Number(value);
|
|
81
|
+
if (!Number.isFinite(parsed)) return null;
|
|
82
|
+
return Math.max(0, Math.min(parsed, 1));
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
function safeRate(num, den) {
|
|
79
86
|
if (!den) return 0;
|
|
80
87
|
return Number((num / den).toFixed(4));
|
|
@@ -480,6 +487,8 @@ function sanitizeTelemetryPayload(payload = {}, headers = {}) {
|
|
|
480
487
|
httpStatus: normalizeInteger(raw.httpStatus),
|
|
481
488
|
userAgent: pickFirstText(raw.userAgent, headers['user-agent']),
|
|
482
489
|
isBot: pickFirstText(raw.isBot),
|
|
490
|
+
interstitialSampled: pickFirstText(raw.interstitialSampled),
|
|
491
|
+
interstitialSampleRate: normalizeRate(raw.interstitialSampleRate),
|
|
483
492
|
attributionTagged: Boolean(
|
|
484
493
|
pickFirstText(raw.utmSource, raw.utmMedium, raw.utmCampaign, raw.utmContent, raw.utmTerm)
|
|
485
494
|
),
|