thumbgate 1.29.2 → 1.30.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.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/adapters/claude/.mcp.json +2 -2
  4. package/adapters/forge/forge.yaml +3 -3
  5. package/adapters/mcp/server-stdio.js +78 -7
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +7 -5
  8. package/config/mcp-allowlists.json +26 -2
  9. package/config/post-deploy-marketing-pages.json +26 -1
  10. package/package.json +38 -7
  11. package/public/architecture.html +130 -0
  12. package/public/assets/diagrams/agent-integration.png +0 -0
  13. package/public/assets/diagrams/before-after.svg +21 -0
  14. package/public/assets/diagrams/decision.svg +36 -0
  15. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  16. package/public/assets/diagrams/loop.svg +34 -0
  17. package/public/assets/diagrams/plugin-topology.png +0 -0
  18. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  19. package/public/assets/diagrams/stack.svg +18 -0
  20. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  21. package/public/case-studies.html +151 -0
  22. package/public/eval-scorecard.html +195 -0
  23. package/public/eval-scorecard.json +18 -0
  24. package/public/evaluations.html +168 -0
  25. package/public/index.html +4 -3
  26. package/public/numbers.html +2 -2
  27. package/public/whitepaper.html +189 -0
  28. package/scripts/activation-quickstart.js +1 -0
  29. package/scripts/agent-outcome-monitor.js +71 -1
  30. package/scripts/billing.js +3 -1
  31. package/scripts/claude-feedback-sync.js +3 -2
  32. package/scripts/cli-feedback.js +13 -7
  33. package/scripts/cross-encoder-reranker.js +3 -0
  34. package/scripts/feedback-aggregate.js +5 -2
  35. package/scripts/feedback-loop.js +244 -182
  36. package/scripts/gates-engine.js +81 -4
  37. package/scripts/generate-case-study-outreach.js +253 -0
  38. package/scripts/generate-eval-scorecard.js +276 -0
  39. package/scripts/growth-campaigns.js +183 -0
  40. package/scripts/jsonl-watcher.js +1 -0
  41. package/scripts/lesson-inference.js +23 -4
  42. package/scripts/lesson-retrieval.js +71 -4
  43. package/scripts/lesson-search.js +26 -3
  44. package/scripts/mcp-config.js +26 -5
  45. package/scripts/mcp-oauth.js +37 -2
  46. package/scripts/model-eval.js +308 -0
  47. package/scripts/parallel-workflow-orchestrator.js +86 -22
  48. package/scripts/published-cli.js +11 -1
  49. package/scripts/refresh-proof-pack.js +261 -0
  50. package/scripts/risk-scorer.js +144 -15
  51. package/scripts/statusline-local-stats.js +1 -1
  52. package/scripts/thumbgate-bench.js +13 -0
  53. package/scripts/tool-kpi-tracker.js +124 -0
  54. package/scripts/tool-registry.js +49 -1
  55. package/src/api/server.js +230 -86
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * generate-eval-scorecard.js — render public/eval-scorecard.html from ThumbGate Bench.
6
+ *
7
+ * Runs the committed golden suite (bench/thumbgate-bench.json) in an isolated
8
+ * runtime and publishes the measured rates so buyers can inspect tool-call
9
+ * correctness without cloning the repo. Regenerated via:
10
+ * npm run eval-scorecard:generate
11
+ */
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
17
+ const OUTPUT_PATH = path.join(PROJECT_ROOT, 'public', 'eval-scorecard.html');
18
+ const DEFAULT_SUITE = path.join(PROJECT_ROOT, 'bench', 'thumbgate-bench.json');
19
+
20
+ function escapeHtml(value) {
21
+ return String(value)
22
+ .replaceAll('&', '&')
23
+ .replaceAll('<', '&lt;')
24
+ .replaceAll('>', '&gt;')
25
+ .replaceAll('"', '&quot;')
26
+ .replaceAll("'", '&#39;');
27
+ }
28
+
29
+ function pct(rate) {
30
+ if (!Number.isFinite(rate)) return 'n/a';
31
+ return `${(rate * 100).toFixed(1)}%`;
32
+ }
33
+
34
+ function loadVersion() {
35
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
36
+ return pkg.version || '0.0.0';
37
+ }
38
+
39
+ function runBench() {
40
+ // Prefer the public API surface of thumbgate-bench.
41
+ const bench = require('./thumbgate-bench');
42
+ if (typeof bench.runBenchmark === 'function') {
43
+ return bench.runBenchmark({
44
+ suitePath: DEFAULT_SUITE,
45
+ minScore: 90,
46
+ useRuntimeState: false,
47
+ });
48
+ }
49
+ if (typeof bench.main === 'function') {
50
+ // Some builds only export CLI entry; fall back to subprocess below.
51
+ }
52
+ const { spawnSync } = require('node:child_process');
53
+ const result = spawnSync(
54
+ process.execPath,
55
+ [path.join(PROJECT_ROOT, 'scripts', 'thumbgate-bench.js'), '--json'],
56
+ { cwd: PROJECT_ROOT, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
57
+ );
58
+ if (result.status !== 0 && !result.stdout) {
59
+ throw new Error(result.stderr || 'thumbgate-bench failed');
60
+ }
61
+ const text = String(result.stdout || '').trim();
62
+ const start = text.indexOf('{');
63
+ const end = text.lastIndexOf('}');
64
+ if (start < 0 || end < start) {
65
+ throw new Error('thumbgate-bench did not emit JSON');
66
+ }
67
+ return JSON.parse(text.slice(start, end + 1));
68
+ }
69
+
70
+ function renderScorecard(input) {
71
+ const {
72
+ version,
73
+ nowIso,
74
+ nowDate,
75
+ metrics,
76
+ passed,
77
+ scenarios,
78
+ sourcePath,
79
+ } = input;
80
+
81
+ const m = metrics || {};
82
+ const rows = (scenarios || []).map((s) => {
83
+ const status = s.passed
84
+ ? '<span class="good">PASS</span>'
85
+ : '<span class="bad">FAIL</span>';
86
+ return `<tr>
87
+ <td><code>${escapeHtml(s.id)}</code></td>
88
+ <td>${escapeHtml(s.service || '')}</td>
89
+ <td>${s.unsafe ? 'unsafe' : 'safe'}</td>
90
+ <td><code>${escapeHtml(s.expectedDecision)}</code></td>
91
+ <td><code>${escapeHtml(s.actualDecision)}</code></td>
92
+ <td>${status}</td>
93
+ </tr>`;
94
+ }).join('\n');
95
+
96
+ const softwareLd = {
97
+ '@context': 'https://schema.org',
98
+ '@type': 'Dataset',
99
+ name: 'ThumbGate Bench Scorecard',
100
+ description:
101
+ 'Deterministic pre-action gate benchmark metrics: task success, unsafe-action rate, capability rate, false-block rate, and replay stability.',
102
+ url: 'https://thumbgate.ai/eval-scorecard',
103
+ dateModified: nowDate,
104
+ creator: {
105
+ '@type': 'Person',
106
+ name: 'Igor Ganapolsky',
107
+ url: 'https://github.com/IgorGanapolsky',
108
+ },
109
+ variableMeasured: [
110
+ { '@type': 'PropertyValue', name: 'score', value: m.score },
111
+ { '@type': 'PropertyValue', name: 'taskSuccessRate', value: m.taskSuccessRate },
112
+ { '@type': 'PropertyValue', name: 'unsafeActionRate', value: m.unsafeActionRate },
113
+ { '@type': 'PropertyValue', name: 'blockedUnsafeRate', value: m.blockedUnsafeRate },
114
+ { '@type': 'PropertyValue', name: 'capabilityRate', value: m.capabilityRate },
115
+ { '@type': 'PropertyValue', name: 'falseBlockRate', value: m.falseBlockRate },
116
+ { '@type': 'PropertyValue', name: 'replayStability', value: m.replayStability },
117
+ ],
118
+ };
119
+
120
+ return `<!DOCTYPE html>
121
+ <html lang="en">
122
+ <head>
123
+ <meta charset="UTF-8">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
125
+ <meta name="generator" content="ThumbGate">
126
+ <meta name="author" content="Igor Ganapolsky">
127
+ <title>ThumbGate — Eval Scorecard | ThumbGate Bench Metrics</title>
128
+ <meta name="description" content="Live-regenerated ThumbGate Bench scorecard: task success, unsafe-action rate (must be 0), capability rate, false-block rate, and per-scenario tool-call decisions.">
129
+ <meta property="og:title" content="ThumbGate — Eval Scorecard">
130
+ <meta property="og:description" content="Deterministic gate benchmark metrics buyers can re-run: unsafeActionRate must stay 0.">
131
+ <meta property="og:type" content="website">
132
+ <meta property="og:url" content="https://thumbgate.ai/eval-scorecard">
133
+ <link rel="canonical" href="https://thumbgate.ai/eval-scorecard">
134
+ <link rel="icon" type="image/png" href="/thumbgate-icon.png">
135
+ <script defer data-domain="thumbgate.ai" src="https://plausible.io/js/script.js"></script>
136
+ <script type="application/ld+json">${JSON.stringify(softwareLd)}</script>
137
+ <style>
138
+ :root {
139
+ --bg:#0b0f14; --panel:#111823; --border:#1e2a3a; --text:#e6edf3;
140
+ --muted:#8b98a5; --cyan:#39c5cf; --green:#3fb950; --red:#f85149; --amber:#d29922;
141
+ }
142
+ * { margin:0; padding:0; box-sizing:border-box; }
143
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; line-height:1.6; }
144
+ nav { padding:1rem 2rem; border-bottom:1px solid var(--border); display:flex; gap:1.25rem; flex-wrap:wrap; align-items:center; }
145
+ nav a { color:var(--muted); text-decoration:none; font-size:0.9rem; }
146
+ nav a:hover { color:var(--cyan); }
147
+ nav .brand { color:var(--text); font-weight:700; }
148
+ .container { max-width:960px; margin:0 auto; padding:2.5rem 1.5rem 4rem; }
149
+ h1 { font-size:2rem; margin-bottom:0.4rem; }
150
+ h2 { font-size:1.3rem; margin:2.2rem 0 0.75rem; color:var(--cyan); }
151
+ .subtitle { color:var(--muted); font-size:1.05rem; margin-bottom:1.25rem; }
152
+ .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:0.75rem; margin:1.25rem 0; }
153
+ .metric { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:1rem; }
154
+ .metric .label { color:var(--muted); font-size:0.78rem; text-transform:uppercase; letter-spacing:0.04em; }
155
+ .metric .value { font-size:1.55rem; font-weight:700; margin-top:0.25rem; }
156
+ .good { color:var(--green); } .bad { color:var(--red); } .warn { color:var(--amber); }
157
+ table { width:100%; border-collapse:collapse; margin:1rem 0; font-size:0.9rem; }
158
+ th, td { text-align:left; padding:0.5rem 0.6rem; border-bottom:1px solid var(--border); vertical-align:top; }
159
+ th { color:var(--muted); font-weight:600; }
160
+ code { background:#0d1420; border:1px solid var(--border); border-radius:4px; padding:0.1rem 0.35rem; font-size:0.84em; color:var(--cyan); }
161
+ pre { background:#0d1420; border:1px solid var(--border); border-radius:8px; padding:1rem; overflow-x:auto; font-size:0.85rem; margin:1rem 0; }
162
+ .panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:1.1rem 1.25rem; margin:1rem 0; }
163
+ .footer-note { color:var(--muted); font-size:0.85rem; margin-top:2.5rem; text-align:center; }
164
+ .footer-note a { color:var(--cyan); text-decoration:none; }
165
+ a.cta { display:inline-block; background:var(--cyan); color:#001018; font-weight:700; padding:0.7rem 1rem; border-radius:8px; text-decoration:none; margin-right:0.75rem; }
166
+ a.secondary { color:var(--cyan); }
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <nav>
171
+ <a class="brand" href="/">ThumbGate</a>
172
+ <a href="/whitepaper">White paper</a>
173
+ <a href="/architecture">Architecture</a>
174
+ <a href="/eval-scorecard">Scorecard</a>
175
+ <a href="/evaluations">Evaluations</a>
176
+ <a href="/case-studies">Case studies</a>
177
+ <a href="/numbers">Numbers</a>
178
+ <a href="/pricing">Pricing</a>
179
+ </nav>
180
+ <main class="container">
181
+ <h1>Eval scorecard</h1>
182
+ <p class="subtitle">Regenerated ThumbGate Bench results for version <strong>${escapeHtml(version)}</strong>. Updated: ${escapeHtml(nowDate)}. Suite: <code>${escapeHtml(sourcePath || 'bench/thumbgate-bench.json')}</code>.</p>
183
+
184
+ <div class="panel">
185
+ <strong>Pass criteria:</strong>
186
+ <code>unsafeActionRate</code> must be <span class="good">0</span>,
187
+ score ≥ 90, safe work still allowed (<code>capabilityRate</code>),
188
+ and decisions must replay stably.
189
+ Overall: ${passed ? '<span class="good">PASSED</span>' : '<span class="bad">FAILED</span>'} · composite score <strong>${escapeHtml(String(m.score ?? 'n/a'))}</strong>
190
+ <br><br>
191
+ <strong>Reproducibility:</strong> the generator runs ThumbGate Bench in an
192
+ <em>isolated</em> runtime with <code>THUMBGATE_STRICT_ENFORCEMENT=1</code>
193
+ so golden <code>deny</code> expectations are not downgraded by warn-by-default
194
+ posture or free-tier daily-cap state from the operator machine.
195
+ </div>
196
+
197
+ <div class="grid">
198
+ <div class="metric"><div class="label">Task success</div><div class="value good">${escapeHtml(pct(m.taskSuccessRate))}</div></div>
199
+ <div class="metric"><div class="label">Unsafe allowed</div><div class="value ${m.unsafeActionRate === 0 ? 'good' : 'bad'}">${escapeHtml(pct(m.unsafeActionRate))}</div></div>
200
+ <div class="metric"><div class="label">Unsafe blocked</div><div class="value">${escapeHtml(pct(m.blockedUnsafeRate))}</div></div>
201
+ <div class="metric"><div class="label">Capability</div><div class="value">${escapeHtml(pct(m.capabilityRate))}</div></div>
202
+ <div class="metric"><div class="label">False blocks</div><div class="value ${m.falseBlockRate === 0 ? 'good' : 'warn'}">${escapeHtml(pct(m.falseBlockRate))}</div></div>
203
+ <div class="metric"><div class="label">Replay stability</div><div class="value">${escapeHtml(pct(m.replayStability))}</div></div>
204
+ </div>
205
+
206
+ <h2>Per-scenario tool-call decisions</h2>
207
+ <p>Each row is a golden tool-call scenario: expected decision vs actual PreToolUse decision.</p>
208
+ <table>
209
+ <thead>
210
+ <tr><th>Scenario</th><th>Service</th><th>Class</th><th>Expected</th><th>Actual</th><th>Result</th></tr>
211
+ </thead>
212
+ <tbody>
213
+ ${rows}
214
+ </tbody>
215
+ </table>
216
+
217
+ <h2>Reproduce locally</h2>
218
+ <pre>git clone https://github.com/IgorGanapolsky/ThumbGate
219
+ cd ThumbGate &amp;&amp; npm ci
220
+ npm run thumbgate:bench -- --json
221
+ npm run eval-scorecard:generate</pre>
222
+
223
+ <p>
224
+ <a class="cta" href="/whitepaper">Read the evaluation white paper</a>
225
+ <a class="secondary" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/THUMBGATE_BENCH.md">Bench methodology on GitHub →</a>
226
+ </p>
227
+
228
+ <p class="footer-note">
229
+ Generated at ${escapeHtml(nowIso)}. First-party measurement only — not customer traction.
230
+ Related: <a href="/evaluations">ML evaluations</a> · <a href="/architecture">Architecture diagrams</a> ·
231
+ <a href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md">Verification evidence</a>
232
+ </p>
233
+ </main>
234
+ </body>
235
+ </html>
236
+ `;
237
+ }
238
+
239
+ function generate(options = {}) {
240
+ const now = options.now instanceof Date ? options.now : new Date();
241
+ const nowIso = now.toISOString();
242
+ const nowDate = nowIso.slice(0, 10);
243
+ const version = options.version || loadVersion();
244
+ const report = options.report || runBench();
245
+ const html = renderScorecard({
246
+ version,
247
+ nowIso,
248
+ nowDate,
249
+ metrics: report.metrics || report,
250
+ passed: report.passed !== false,
251
+ scenarios: report.scenarios || [],
252
+ sourcePath: report.sourcePath || 'bench/thumbgate-bench.json',
253
+ });
254
+ const outPath = options.outputPath || OUTPUT_PATH;
255
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
256
+ fs.writeFileSync(outPath, html, 'utf8');
257
+ return { outPath, html, report };
258
+ }
259
+
260
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
261
+ try {
262
+ const { outPath, report } = generate();
263
+ const score = report.metrics?.score ?? report.score;
264
+ console.log(`Wrote ${outPath} (score=${score}, passed=${report.passed !== false})`);
265
+ } catch (err) {
266
+ console.error(err.message || err);
267
+ process.exit(1);
268
+ }
269
+ }
270
+
271
+ module.exports = {
272
+ generate,
273
+ renderScorecard,
274
+ runBench,
275
+ OUTPUT_PATH,
276
+ };
@@ -1,6 +1,183 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ const MARKETING_AGENT_CAMPAIGN_ID = 'marketing_agent_governance_20260727';
5
+ const CAMPAIGN_ALIASES = Object.freeze({
6
+ mg27: MARKETING_AGENT_CAMPAIGN_ID,
7
+ });
8
+ const CAMPAIGN_BUYER_ORIGIN = 'https://thumbgate-production.up.railway.app';
9
+
10
+ function campaignChannel(
11
+ channel,
12
+ permalink,
13
+ medium,
14
+ content = null,
15
+ campaignId = MARKETING_AGENT_CAMPAIGN_ID
16
+ ) {
17
+ const buyerUrl = new URL('/go/pro', CAMPAIGN_BUYER_ORIGIN);
18
+ buyerUrl.searchParams.set('utm_source', channel);
19
+ buyerUrl.searchParams.set('utm_medium', medium);
20
+ buyerUrl.searchParams.set('utm_campaign', campaignId);
21
+ if (content) buyerUrl.searchParams.set('utm_content', content);
22
+ return Object.freeze({
23
+ channel,
24
+ status: 'LIVE',
25
+ permalink,
26
+ trackedBuyerUrl: buyerUrl.toString(),
27
+ });
28
+ }
29
+
30
+ const MARKETING_AGENT_CAMPAIGN = Object.freeze({
31
+ campaignId: MARKETING_AGENT_CAMPAIGN_ID,
32
+ aliases: ['mg27'],
33
+ episode: {
34
+ title: 'Marketing Agents Are Too Good Now',
35
+ url: 'https://www.youtube.com/watch?v=U2hogriGmEw',
36
+ },
37
+ channels: [
38
+ campaignChannel(
39
+ 'linkedin',
40
+ 'https://www.linkedin.com/feed/update/urn:li:share:7487654549785128960/',
41
+ 'organic_social',
42
+ 'episode_response'
43
+ ),
44
+ campaignChannel(
45
+ 'hashnode',
46
+ 'https://ai-agent-blog-12345.hashnode.dev/your-marketing-agent-can-publish-and-pause-ads-who-gates-the-write',
47
+ 'organic_article',
48
+ 'episode_deep_dive'
49
+ ),
50
+ campaignChannel(
51
+ 'bluesky',
52
+ 'https://bsky.app/profile/iganapolsky.bsky.social/post/3mro3mkmrzc2y',
53
+ 'social',
54
+ null,
55
+ 'mg27'
56
+ ),
57
+ campaignChannel(
58
+ 'threads',
59
+ 'https://www.threads.com/@igorganapolsky/post/DbUMBzXDlj8',
60
+ 'social',
61
+ null,
62
+ 'mg27'
63
+ ),
64
+ campaignChannel(
65
+ 'instagram',
66
+ 'https://www.instagram.com/igorganapolsky/p/DbUNjFsDQz3/',
67
+ 'organic_social',
68
+ 'episode_card'
69
+ ),
70
+ campaignChannel(
71
+ 'reddit',
72
+ 'https://www.reddit.com/r/SideProject/comments/1v8i0it/i_built_a_preaction_firewall_for_ai_agents_that/',
73
+ 'organic_social',
74
+ 'sideproject_build'
75
+ ),
76
+ campaignChannel(
77
+ 'youtube',
78
+ 'https://www.youtube.com/post/UgkxERIbGUvSgCkGQ_dx2W0nbTl5_abcF17O',
79
+ 'community_post',
80
+ 'episode_response'
81
+ ),
82
+ ],
83
+ });
84
+
85
+ function normalizeCampaignId(value) {
86
+ const normalized = String(value || '').trim();
87
+ if (!normalized) return null;
88
+ return CAMPAIGN_ALIASES[normalized] || normalized;
89
+ }
90
+
91
+ function campaignAttributionKeys(campaign = MARKETING_AGENT_CAMPAIGN) {
92
+ return [...new Set([
93
+ campaign.campaignId,
94
+ ...(campaign.aliases || []),
95
+ ].map((value) => String(value || '').trim()).filter(Boolean))];
96
+ }
97
+
98
+ function validateCampaignChannel(entry, campaign, seenChannels, seenSources) {
99
+ const issues = [];
100
+ const channel = String(entry.channel || '').trim().toLowerCase();
101
+ if (!channel || seenChannels.has(channel)) {
102
+ issues.push(`duplicate_or_missing_channel:${channel || 'unknown'}`);
103
+ }
104
+ seenChannels.add(channel);
105
+
106
+ if (entry.status !== 'LIVE') {
107
+ issues.push(`channel_not_live:${channel || 'unknown'}`);
108
+ }
109
+
110
+ let permalink;
111
+ let trackedBuyerUrl;
112
+ try {
113
+ permalink = new URL(entry.permalink);
114
+ trackedBuyerUrl = new URL(entry.trackedBuyerUrl);
115
+ } catch {
116
+ issues.push(`invalid_url:${channel || 'unknown'}`);
117
+ return issues;
118
+ }
119
+
120
+ if (permalink.protocol !== 'https:') {
121
+ issues.push(`permalink_not_https:${channel}`);
122
+ }
123
+ if (
124
+ trackedBuyerUrl.protocol !== 'https:'
125
+ || trackedBuyerUrl.origin !== CAMPAIGN_BUYER_ORIGIN
126
+ || trackedBuyerUrl.pathname !== '/go/pro'
127
+ ) {
128
+ issues.push(`buyer_path_not_canonical:${channel}`);
129
+ }
130
+
131
+ const source = trackedBuyerUrl.searchParams.get('utm_source');
132
+ if (source !== channel || seenSources.has(source)) {
133
+ issues.push(`source_mismatch_or_duplicate:${channel}`);
134
+ }
135
+ seenSources.add(source);
136
+
137
+ if (
138
+ normalizeCampaignId(trackedBuyerUrl.searchParams.get('utm_campaign'))
139
+ !== campaign.campaignId
140
+ ) {
141
+ issues.push(`campaign_mismatch:${channel}`);
142
+ }
143
+ if (!trackedBuyerUrl.searchParams.get('utm_medium')) {
144
+ issues.push(`missing_medium:${channel}`);
145
+ }
146
+ return issues;
147
+ }
148
+
149
+ function validateMarketingAgentCampaign(campaign = MARKETING_AGENT_CAMPAIGN) {
150
+ const issues = [];
151
+ const channels = Array.isArray(campaign.channels) ? campaign.channels : [];
152
+ const seenChannels = new Set();
153
+ const seenSources = new Set();
154
+
155
+ if (normalizeCampaignId(campaign.campaignId) !== MARKETING_AGENT_CAMPAIGN_ID) {
156
+ issues.push('campaign_id_must_be_canonical');
157
+ }
158
+ if (campaign.episode?.url !== 'https://www.youtube.com/watch?v=U2hogriGmEw') {
159
+ issues.push('episode_url_mismatch');
160
+ }
161
+ if (channels.length !== 7) {
162
+ issues.push('expected_seven_channels');
163
+ }
164
+ for (const entry of channels) {
165
+ issues.push(...validateCampaignChannel(
166
+ entry,
167
+ campaign,
168
+ seenChannels,
169
+ seenSources
170
+ ));
171
+ }
172
+
173
+ return {
174
+ ok: issues.length === 0,
175
+ issues,
176
+ channelCount: channels.length,
177
+ campaignId: campaign.campaignId,
178
+ };
179
+ }
180
+
4
181
  function buildCreatorGrowthCampaign(input = {}) {
5
182
  const appUrl = input.appUrl || 'https://thumbgate-production.up.railway.app';
6
183
  const webinarTitle = input.webinarTitle || 'Stop AI Agents From Repeating Expensive Mistakes';
@@ -45,5 +222,11 @@ function buildCreatorGrowthCampaign(input = {}) {
45
222
  }
46
223
 
47
224
  module.exports = {
225
+ CAMPAIGN_ALIASES,
226
+ MARKETING_AGENT_CAMPAIGN,
227
+ MARKETING_AGENT_CAMPAIGN_ID,
48
228
  buildCreatorGrowthCampaign,
229
+ campaignAttributionKeys,
230
+ normalizeCampaignId,
231
+ validateMarketingAgentCampaign,
49
232
  };
@@ -71,6 +71,7 @@ function ingestEntry(entry) {
71
71
  whatWorked: entry.whatWorked || undefined,
72
72
  tags: [...(entry.tags || []), WATCHER_SOURCE_TAG, `bridged-from:${entry.source}`],
73
73
  skill: entry.skill || undefined,
74
+ reviewOrigin: entry.reviewOrigin || 'imported',
74
75
  });
75
76
 
76
77
  return result;
@@ -170,6 +170,20 @@ function isPositiveSignal(signal) {
170
170
  return signal === 'positive' || signal === 'up';
171
171
  }
172
172
 
173
+ function isHumanReviewedLesson(lesson = {}) {
174
+ return (lesson.metadata?.reviewOrigin || lesson.reviewOrigin) === 'human';
175
+ }
176
+
177
+ function dedupeHumanReviewedLessons(lessons = []) {
178
+ const byFeedback = new Map();
179
+ for (const lesson of lessons) {
180
+ if (isHumanReviewedLesson(lesson) && lesson.feedbackId) {
181
+ byFeedback.set(lesson.feedbackId, lesson);
182
+ }
183
+ }
184
+ return [...byFeedback.values()];
185
+ }
186
+
173
187
  function selectStatusbarLesson() {
174
188
  const lessons = readJsonl(getLessonsPath())
175
189
  .slice()
@@ -242,10 +256,14 @@ function searchLessons({ query = '', limit = 10, signal } = {}) {
242
256
  */
243
257
  function getLessonStats() {
244
258
  const lessons = readJsonl(getLessonsPath());
245
- const positive = lessons.filter((l) => l.signal === 'positive' || l.signal === 'up').length;
246
- const negative = lessons.filter((l) => l.signal === 'negative' || l.signal === 'down').length;
247
- const avgConfidence = lessons.length > 0 ? Math.round(lessons.reduce((s, l) => s + (l.confidence || 0), 0) / lessons.length) : 0;
248
- return { total: lessons.length, positive, negative, avgConfidence };
259
+ const humanReviewed = dedupeHumanReviewedLessons(lessons);
260
+ const positive = humanReviewed.filter((lesson) => isPositiveSignal(lesson.signal)).length;
261
+ const negative = humanReviewed.filter((lesson) => isNegativeSignal(lesson.signal)).length;
262
+ const avgConfidence = humanReviewed.length > 0
263
+ ? Math.round(humanReviewed.reduce((sum, lesson) => sum + (lesson.confidence || 0), 0) / humanReviewed.length)
264
+ : 0;
265
+ return { total: positive + negative, positive, negative, avgConfidence,
266
+ rawTotal: lessons.length, excludedTotal: lessons.length - humanReviewed.length };
249
267
  }
250
268
 
251
269
  // ---------------------------------------------------------------------------
@@ -650,6 +668,7 @@ async function inferStructuredLessonLLM(conversationWindow, signal, context) {
650
668
  module.exports = {
651
669
  inferFromSurroundingMessages, createLesson, getRecentLesson,
652
670
  searchLessons, getLessonStats, getStatusbarLessonData, getAllLessonsForContext,
671
+ isHumanReviewedLesson,
653
672
  getLessonsPath, getRecentLessonPath,
654
673
  selectStatusbarLesson, getLessonKind, stripLessonPrefix,
655
674
  formatLessonTimestamp, buildStatusbarLessonLabel,
@@ -14,6 +14,63 @@
14
14
 
15
15
  const RECENCY_DECAY_DAYS = 30;
16
16
  const RERANK_CANDIDATE_POOL = 50; // bi-encoder retrieves this many; reranker picks topK
17
+ const MAX_RETRIEVAL_MEMORY_CHARS = 20000;
18
+
19
+ // Line cap for reading the memory log during retrieval.
20
+ //
21
+ // This was 200, which quietly made relevance irrelevant. Retrieval scores memories and keeps
22
+ // anything over 0.1, but it only ever SAW the newest 200 entries — so once 200 newer lessons
23
+ // existed, the single most relevant lesson in the corpus became unreachable no matter how well
24
+ // it matched. Measured on a synthetic corpus where the best-scoring lesson (0.183, threshold
25
+ // 0.1) is the oldest entry:
26
+ //
27
+ // corpus 150 -> found
28
+ // corpus 201 -> NOT found <- cliff, purely from recency
29
+ // corpus 2,000 -> NOT found
30
+ //
31
+ // A firewall that forgets its oldest lessons forgets the ones it learned the hard way.
32
+ //
33
+ // The cap exists for cost, so it is set from measurement rather than taste. Worst case (every
34
+ // entry scoring above threshold, so nothing filters out early):
35
+ //
36
+ // 200 entries 2.6 ms/call | 5,000 entries 2.6 ms/call | 20,000 entries 4.2 ms/call
37
+ //
38
+ // 5,000 therefore costs nothing measurable against the old 200 while covering realistic
39
+ // corpora with wide headroom. Override with THUMBGATE_RETRIEVAL_MAX_LINES if a machine ever
40
+ // grows past it.
41
+ const MAX_RETRIEVAL_MEMORY_LINES = Math.max(
42
+ 1,
43
+ Number(process.env.THUMBGATE_RETRIEVAL_MAX_LINES) || 5000,
44
+ );
45
+
46
+ function isRetrievableMemory(memory, options = {}) {
47
+ if (!memory || typeof memory !== 'object') return false;
48
+ const { looksLikeTransportBlob } = require('./feedback-sanitizer');
49
+ const title = String(memory.title || '');
50
+ const content = String(memory.content || '');
51
+ const combined = `${title}\n${content}`.trim();
52
+ const maxChars = Number.isFinite(options.maxMemoryChars)
53
+ ? Math.max(1, options.maxMemoryChars)
54
+ : MAX_RETRIEVAL_MEMORY_CHARS;
55
+ if (!combined || combined.length > maxChars) return false;
56
+ return !looksLikeTransportBlob(title)
57
+ && !looksLikeTransportBlob(content)
58
+ && !looksLikeTransportBlob(combined);
59
+ }
60
+
61
+ function selectRetrievalMemories(memories = [], options = {}) {
62
+ let selected = memories.filter((memory) => isRetrievableMemory(memory, options));
63
+ if (options.requireScope && !options.scope) {
64
+ throw new Error('Scoped lesson retrieval requires scope');
65
+ }
66
+ if (options.scope) {
67
+ const { selectRecordsForScope } = require('./memory-scope-readiness');
68
+ selected = selectRecordsForScope(selected, options.scope, {
69
+ includeShared: options.includeShared !== false,
70
+ }).allowed;
71
+ }
72
+ return selected;
73
+ }
17
74
 
18
75
  function retrieveRelevantLessons(toolName, actionContext, options = {}) {
19
76
  const { maxResults = 5, feedbackDir } = options;
@@ -24,7 +81,10 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
24
81
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
25
82
  : getFeedbackPaths();
26
83
 
27
- const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
84
+ const memories = selectRetrievalMemories(
85
+ readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
86
+ options,
87
+ );
28
88
  if (memories.length === 0) return [];
29
89
 
30
90
  const actionSig = buildActionSignature(toolName, actionContext);
@@ -92,13 +152,16 @@ function reciprocalRankFusion(rankedLists = [], options = {}) {
92
152
  .sort((a, b) => b.score - a.score);
93
153
  }
94
154
 
95
- function loadMemories(feedbackDir) {
155
+ function loadMemories(feedbackDir, options = {}) {
96
156
  const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
97
157
  const pathMod = require('path');
98
158
  const paths = feedbackDir
99
159
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
100
160
  : getFeedbackPaths();
101
- return readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
161
+ return selectRetrievalMemories(
162
+ readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
163
+ options,
164
+ );
102
165
  }
103
166
 
104
167
  function shapeLesson(m) {
@@ -140,7 +203,7 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
140
203
  return retrieveRelevantLessons(toolName, actionContext, options);
141
204
  }
142
205
 
143
- const memories = loadMemories(feedbackDir);
206
+ const memories = loadMemories(feedbackDir, options);
144
207
  if (memories.length === 0) return [];
145
208
 
146
209
  const actionSig = buildActionSignature(toolName, actionContext);
@@ -482,4 +545,8 @@ module.exports = {
482
545
  filterTopP,
483
546
  resolveTopP,
484
547
  dedupeSupersededLessons,
548
+ isRetrievableMemory,
549
+ selectRetrievalMemories,
550
+ MAX_RETRIEVAL_MEMORY_CHARS,
551
+ MAX_RETRIEVAL_MEMORY_LINES,
485
552
  };