thumbgate 1.29.2 → 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.
Files changed (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -84,8 +84,32 @@ function regressionCheck(gate, options = {}) {
84
84
  let matchesGate;
85
85
  try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
86
86
  if (typeof matchesGate !== 'function') return null;
87
- const allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
87
+ let allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
88
88
  if (!allowed.length) return null;
89
+
90
+ // The command we just learned to block was, by definition, ALLOWED before we
91
+ // learned it — that prior allow IS the incident the operator thumbs-downed.
92
+ // Counting it as a false block quarantines every gate learned from a real
93
+ // failure, which is the normal path (run it, get burned, 👎 it). Exclude the
94
+ // originating contexts so the check only measures collateral damage to
95
+ // genuinely unrelated actions.
96
+ // Match on normalized command EQUALITY, not substring containment: a longer,
97
+ // genuinely different command that merely quotes the incident text (e.g.
98
+ // `notify-team --dry-run "<incident>"`) is real collateral damage and must
99
+ // still count toward quarantine.
100
+ const incidentSignatures = new Set(
101
+ (options.incidentContexts || [])
102
+ .map((c) => normalizeCommandSignature(String(c || '')))
103
+ .filter(Boolean),
104
+ );
105
+ if (incidentSignatures.size > 0) {
106
+ allowed = allowed.filter((e) => {
107
+ const cmd = (e.toolInput && (e.toolInput.command || e.toolInput.pattern)) || '';
108
+ return !incidentSignatures.has(normalizeCommandSignature(String(cmd)));
109
+ });
110
+ if (!allowed.length) return { falseBlocks: 0, allowSampleSize: 0 };
111
+ }
112
+
89
113
  let falseBlocks = 0;
90
114
  for (const e of allowed) {
91
115
  try {
@@ -163,14 +187,43 @@ function normalizeCommandSignature(input) {
163
187
  return tokens.join(' ').trim();
164
188
  }
165
189
 
166
- function extractPatternKey(entry) {
167
- // Use tags as primary grouping key; fall back to context normalization
168
- const tags = (entry.tags || []).filter((t) => !['feedback', 'negative', 'positive'].includes(t));
169
- if (tags.length > 0) return tags.sort().join('+');
190
+ /**
191
+ * Prefer an executable action we can match at PreToolUse time.
192
+ * Tag-only or pure prose feedback is useful memory — not an enforcement pattern.
193
+ */
194
+ function extractExecutableAction(entry) {
195
+ if (!entry || typeof entry !== 'object') return null;
196
+ const fromTool =
197
+ (entry.toolInput && (entry.toolInput.command || entry.toolInput.pattern))
198
+ || (entry.tool_input && (entry.tool_input.command || entry.tool_input.pattern))
199
+ || entry.command
200
+ || entry.failedCommand
201
+ || null;
202
+ if (fromTool && String(fromTool).trim().length >= 4) {
203
+ return String(fromTool).trim();
204
+ }
170
205
 
171
- const ctx = (entry.context || entry.whatWentWrong || '').trim();
172
- if (ctx.length < 10) return null;
173
- return normalizeCommandSignature(ctx).slice(0, 100);
206
+ const ctx = String(entry.context || entry.whatWentWrong || '').trim();
207
+ if (ctx.length < 4) return null;
208
+
209
+ // Looks like a shell / CLI invocation (not free-form prose).
210
+ const looksExecutable = /^(?:sudo\s+)?(?:~\/|\.\/|\/)?(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+(?:\s|$)/.test(ctx)
211
+ && /\s|^[a-z0-9._+-]+(?:\s|$)/i.test(ctx)
212
+ && !/\s+(?:broke|failed|wrong|should|never|please|the agent)\b/i.test(ctx.slice(0, 80));
213
+ // Strong signal: known tool prefixes
214
+ const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
215
+ if (known || (looksExecutable && /[\s-]/.test(ctx) && ctx.length <= 200)) {
216
+ return ctx;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function extractPatternKey(entry) {
222
+ // Enforcement groups by executable action only. Tags remain diagnostic metadata
223
+ // and must not create hard-block thresholds for a single unrelated latest command.
224
+ const action = extractExecutableAction(entry);
225
+ if (!action) return null;
226
+ return normalizeCommandSignature(action).slice(0, 100);
174
227
  }
175
228
 
176
229
  function extractDiagnosticKeys(entry) {
@@ -203,27 +256,29 @@ function groupNegativeFeedback(entries, windowDays) {
203
256
  const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
204
257
  if (ts < cutoff) continue;
205
258
 
206
- const keys = [extractPatternKey(entry), ...extractDiagnosticKeys(entry)]
207
- .filter(Boolean)
208
- .filter((value, index, values) => values.indexOf(value) === index);
209
- if (keys.length === 0) continue;
210
-
211
- for (const key of keys) {
212
- if (!groups[key]) {
213
- groups[key] = {
214
- key,
215
- count: 0,
216
- entries: [],
217
- latestContext: '',
218
- latestTimestamp: '',
219
- };
220
- }
221
- groups[key].count++;
222
- groups[key].entries.push(entry);
223
- if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
224
- groups[key].latestTimestamp = entry.timestamp || '';
225
- groups[key].latestContext = entry.context || entry.whatWentWrong || '';
226
- }
259
+ // Enforcement groups ONLY by executable action. Tag/diagnosis metadata is
260
+ // useful for memory and dashboards, but must not create hard-block thresholds
261
+ // that attach to an unrelated latest command.
262
+ const key = extractPatternKey(entry);
263
+ if (!key) continue;
264
+
265
+ if (!groups[key]) {
266
+ groups[key] = {
267
+ key,
268
+ count: 0,
269
+ entries: [],
270
+ latestContext: '',
271
+ latestTimestamp: '',
272
+ latestExecutable: '',
273
+ };
274
+ }
275
+ groups[key].count++;
276
+ groups[key].entries.push(entry);
277
+ if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
278
+ groups[key].latestTimestamp = entry.timestamp || '';
279
+ const action = extractExecutableAction(entry);
280
+ groups[key].latestContext = action || entry.context || entry.whatWentWrong || '';
281
+ groups[key].latestExecutable = action || '';
227
282
  }
228
283
  }
229
284
 
@@ -234,15 +289,48 @@ function patternToGateId(key) {
234
289
  return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
235
290
  }
236
291
 
292
+ /**
293
+ * Turn a captured context string into a pattern the gates engine can actually
294
+ * match. `gates-engine.js` compiles `gate.pattern` with `new RegExp(...)` and
295
+ * tests it against the tool-call text, so the pattern MUST be regex-safe text
296
+ * drawn from the command itself.
297
+ *
298
+ * It must NOT be the group key: keys are frequently tag-derived
299
+ * ("entity:Customer+entity:Funnel"), which is both meaningless against a command
300
+ * string and actively hazardous as a regex ('+' is a quantifier). Grouping by tag
301
+ * is correct — reusing that key as the match pattern is not.
302
+ */
303
+ function contextToPattern(context) {
304
+ const raw = String(context || '').trim();
305
+ if (raw.length < 4) return null;
306
+ // Escape every regex metacharacter: the captured command is literal text.
307
+ return raw.slice(0, 120).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
+ }
309
+
310
+ /**
311
+ * A gate that cannot match the very context that produced it is inert — it
312
+ * shows up in the dashboard as an active blocking rule while enforcing nothing.
313
+ * That failure mode is worse than no gate at all, so callers drop these.
314
+ */
315
+ function gateMatchesOwnContext(gate, context) {
316
+ if (!gate || !gate.pattern) return false;
317
+ try {
318
+ return new RegExp(gate.pattern).test(String(context || ''));
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
237
324
  function buildGateRule(group, actionOverride) {
238
325
  const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
239
326
  const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
240
- const context = group.latestContext.slice(0, 120);
327
+ const executable = (group.latestExecutable || extractExecutableAction({ context: group.latestContext }) || group.latestContext || '').slice(0, 120);
328
+ const context = executable;
241
329
  const kind = group.key.startsWith('diagnosis:')
242
330
  ? 'repeated diagnosis'
243
331
  : group.key.startsWith('constraint:')
244
332
  ? 'repeated constraint violation'
245
- : 'repeated pattern';
333
+ : 'repeated executable action';
246
334
 
247
335
  const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
248
336
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
@@ -257,7 +345,8 @@ function buildGateRule(group, actionOverride) {
257
345
  return {
258
346
  id: patternToGateId(group.key),
259
347
  trigger: `auto:${group.key}`,
260
- pattern: group.key.replace(/^diagnosis:|constraint:/, ''),
348
+ // Derived from the executable action, NOT from tag keys — see contextToPattern.
349
+ pattern: contextToPattern(executable),
261
350
  action,
262
351
  message: suggestedMessage,
263
352
  severity,
@@ -393,6 +482,16 @@ function promote(feedbackLogPath, options) {
393
482
 
394
483
  const gateId = patternToGateId(group.key);
395
484
 
485
+ // Contexts that produced this gate. Their prior "allow" decisions are the
486
+ // incident the operator thumbs-downed, not false positives — both the
487
+ // new-gate and the warn->block upgrade path must exclude them from the
488
+ // regression check, or every gate learned from a real failure is held at warn.
489
+ const incidentContexts = [
490
+ group.latestContext,
491
+ ...(group.entries || []).map((e) => e && (e.context || e.whatWentWrong)),
492
+ ].filter(Boolean);
493
+ const regressionOpts = { ...opts, incidentContexts };
494
+
396
495
  // Check for existing gate — possibly upgrade
397
496
  const existingIdx = data.gates.findIndex((g) => g.id === gateId);
398
497
  if (existingIdx !== -1) {
@@ -400,7 +499,7 @@ function promote(feedbackLogPath, options) {
400
499
  const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
401
500
  if (existing.action !== newAction && newAction === 'block') {
402
501
  // Self-Harness stage 3: regression-test before upgrading warn -> block.
403
- const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), opts);
502
+ const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), regressionOpts);
404
503
  if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
405
504
  // Would block prior safe actions — hold at warn instead of upgrading.
406
505
  promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
@@ -418,12 +517,25 @@ function promote(feedbackLogPath, options) {
418
517
  // New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
419
518
  const gate = buildGateRule(group, opts.gateAction);
420
519
 
520
+ // Never persist a gate that cannot match the context that produced it. Such a
521
+ // gate renders in the dashboard as an active blocking rule while enforcing
522
+ // nothing, which reads as "the agent learned" when it did not.
523
+ if (!gateMatchesOwnContext(gate, group.latestContext)) {
524
+ promotions.push({
525
+ type: 'skipped-unmatchable',
526
+ gateId: gate.id,
527
+ reason: 'derived pattern does not match originating context',
528
+ occurrences: group.count,
529
+ });
530
+ continue;
531
+ }
532
+
421
533
  // Self-Harness stage 3: before a feedback rule goes live as a hard block,
422
534
  // regression-test it against prior allowed actions. If it would have blocked
423
535
  // safe actions, quarantine it to `warn` instead of `block`.
424
536
  let regression = null;
425
537
  if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
426
- regression = safeRegressionCheck(gate, opts);
538
+ regression = safeRegressionCheck(gate, regressionOpts);
427
539
  if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
428
540
  gate.action = 'warn';
429
541
  gate.severity = 'medium';
@@ -506,10 +618,13 @@ module.exports = {
506
618
  groupNegativeFeedback,
507
619
  patternToGateId,
508
620
  buildGateRule,
621
+ contextToPattern,
622
+ gateMatchesOwnContext,
509
623
  regressionCheck,
510
624
  getAuditTrailPath,
511
625
  REGRESSION_FALSE_BLOCK_LIMIT,
512
626
  extractPatternKey,
627
+ extractExecutableAction,
513
628
  normalizeCommandSignature,
514
629
  isNegative,
515
630
  expireGates,
@@ -30,6 +30,7 @@ const {
30
30
  resolveFallbackArtifactPath,
31
31
  } = require('./feedback-paths');
32
32
  const { getTelemetryAnalytics, getTelemetrySourceDiagnostics } = require('./telemetry-analytics');
33
+ const { normalizeCampaignId } = require('./growth-campaigns');
33
34
  const {
34
35
  PRO_MONTHLY_PRICE_ID,
35
36
  PRO_ANNUAL_PRICE_ID,
@@ -1154,7 +1155,7 @@ function extractAttribution(metadata = {}) {
1154
1155
  return {
1155
1156
  source: normalizeText(safe.utmSource || safe.source),
1156
1157
  medium: normalizeText(safe.utmMedium || safe.medium),
1157
- campaign: normalizeText(safe.utmCampaign || safe.campaign),
1158
+ campaign: normalizeCampaignId(safe.utmCampaign || safe.campaign),
1158
1159
  content: normalizeText(safe.utmContent || safe.content),
1159
1160
  term: normalizeText(safe.utmTerm || safe.term),
1160
1161
  creator: normalizeText(safe.creator || safe.creatorHandle || safe.creator_handle),
@@ -4237,6 +4238,7 @@ module.exports = {
4237
4238
  _TRIAL_EMAIL_LEDGER_PATH: () => CONFIG.TRIAL_EMAIL_LEDGER_PATH,
4238
4239
  _ORDER_EMAIL_LEDGER_PATH: () => CONFIG.ORDER_EMAIL_LEDGER_PATH,
4239
4240
  _LOCAL_MODE: () => LOCAL_MODE(),
4241
+ _extractAttribution: extractAttribution,
4240
4242
  _withTimeout: withTimeout,
4241
4243
  _mailer: mailer,
4242
4244
  };
@@ -8,7 +8,6 @@ const {
8
8
  buildFeedbackSourceIdentity,
9
9
  getFeedbackPaths,
10
10
  readJSONL,
11
- analyzeFeedback,
12
11
  } = require('./feedback-loop');
13
12
  const { detectFeedbackSignal, normalizeFeedbackText } = require('./feedback-quality');
14
13
  const {
@@ -279,6 +278,7 @@ function syncClaudeHistoryFeedback(options = {}) {
279
278
  whatWorked: candidate.signal === 'up' ? candidate.promptText : undefined,
280
279
  tags: ['claude-history-sync', 'auto-capture-fallback'],
281
280
  sourceEvent,
281
+ reviewOrigin: 'human',
282
282
  });
283
283
 
284
284
  if (captureResult?.duplicate) {
@@ -300,7 +300,8 @@ function syncClaudeHistoryFeedback(options = {}) {
300
300
  }, { feedbackDir });
301
301
 
302
302
  if (importedCount > 0) {
303
- refreshStatuslineCache(analyzeFeedback(path.join(feedbackDir, 'feedback-log.jsonl')), path.join(feedbackDir, 'statusline_cache.json'));
303
+ const { analyzeFeedback } = require('./feedback-loop');
304
+ refreshStatuslineCache(analyzeFeedback(path.join(feedbackDir, 'feedback-log.jsonl'), { humanOnly: true }), path.join(feedbackDir, 'statusline_cache.json'));
304
305
  }
305
306
 
306
307
  return {
@@ -13,13 +13,9 @@
13
13
  * node scripts/cli-feedback.js down "what went wrong"
14
14
  */
15
15
 
16
- const { captureFeedback } = require('./feedback-loop');
16
+ const { captureFeedback, analyzeFeedback } = require('./feedback-loop');
17
17
  const { loadOptionalModule } = require('./private-core-boundary');
18
- // `history-distiller` is a PRIVATE_CORE_MODULE present in this checkout and
19
- // in ThumbGate-Core, but intentionally excluded from the public npm tarball.
20
- // The hard `require('./history-distiller')` form crashed `hook-auto-capture`
21
- // in published 1.19.0 with MODULE_NOT_FOUND. Public-shell fallback returns
22
- // null distillation; caller already handles a null distillResult.
18
+ // Keep the optional distiller from breaking the public package.
23
19
  const { distillFromHistory } = loadOptionalModule('./history-distiller', () => ({
24
20
  distillFromHistory: () => null,
25
21
  }));
@@ -60,6 +56,7 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
60
56
  whatWorked: whatWorked || undefined,
61
57
  chatHistory,
62
58
  sourceEvent,
59
+ reviewOrigin: 'human',
63
60
  });
64
61
  } catch (err) {
65
62
  feedbackResult = { accepted: false, reason: err.message };
@@ -75,7 +72,16 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
75
72
 
76
73
  // 3. Get the most recent lesson and stats
77
74
  const recentLesson = getRecentLesson();
78
- const stats = getLessonStats();
75
+ const lessonStats = getLessonStats();
76
+ const feedbackStats = analyzeFeedback(undefined, { humanOnly: true });
77
+ const stats = {
78
+ ...lessonStats,
79
+ total: feedbackStats.total,
80
+ positive: feedbackStats.totalPositive,
81
+ negative: feedbackStats.totalNegative,
82
+ rawTotal: feedbackStats.rawTotal,
83
+ excludedTotal: feedbackStats.excludedTotal,
84
+ };
79
85
 
80
86
  // 4. If the user wrote an explicit "never …" / "always …" directive, surface
81
87
  // an OFFER (never auto-act): "never" on a thumbs-down → offer force-gate now.
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ColBERT-style late interaction (MaxSim) for ThumbGate lesson reranking.
5
+ *
6
+ * This is NOT a pretrained ColBERT neural model. It implements the *interaction
7
+ * pattern* ColBERT made famous:
8
+ * - encode query and document as multi-vector bags (one vector per token)
9
+ * - score with MaxSim: sum over query tokens of max cosine vs any doc token
10
+ *
11
+ * Token vectors are deterministic hashed character-n-gram projections (local-only,
12
+ * no GPU, no network). That gives late interaction without shipping a 100MB model
13
+ * in the npm package — while remaining honest about model provenance.
14
+ *
15
+ * For true neural ColBERT, operators can plug vectors via `tokenEmbedder`.
16
+ *
17
+ * @see https://arxiv.org/abs/2004.12832 (ColBERT MaxSim)
18
+ */
19
+
20
+ const DEFAULT_DIM = 32;
21
+ const DEFAULT_NGRAM = 3;
22
+
23
+ /**
24
+ * Deterministic 32-bit hash (FNV-1a style) for seedable projections.
25
+ * @param {string} str
26
+ * @returns {number}
27
+ */
28
+ function hash32(str) {
29
+ let h = 0x811c9dc5;
30
+ for (let i = 0; i < str.length; i += 1) {
31
+ h ^= str.charCodeAt(i);
32
+ h = Math.imul(h, 0x01000193);
33
+ }
34
+ return h >>> 0;
35
+ }
36
+
37
+ /**
38
+ * Tokenize into lowercase word tokens (length >= 2).
39
+ * @param {string} text
40
+ * @returns {string[]}
41
+ */
42
+ function tokenize(text) {
43
+ if (!text) return [];
44
+ return String(text)
45
+ .toLowerCase()
46
+ .replace(/[^\w\s]/g, ' ')
47
+ .split(/[\s_]+/)
48
+ .filter((t) => t.length >= 2);
49
+ }
50
+
51
+ /**
52
+ * Character n-grams for a token (with edge markers).
53
+ * @param {string} token
54
+ * @param {number} n
55
+ * @returns {string[]}
56
+ */
57
+ function charNgrams(token, n = DEFAULT_NGRAM) {
58
+ const padded = `#${token}#`;
59
+ if (padded.length < n) return [padded];
60
+ const grams = [];
61
+ for (let i = 0; i <= padded.length - n; i += 1) {
62
+ grams.push(padded.slice(i, i + n));
63
+ }
64
+ return grams;
65
+ }
66
+
67
+ /**
68
+ * Build a unit-length multi-dim embedding for one token via hashed n-grams.
69
+ * @param {string} token
70
+ * @param {{ dim?: number, ngram?: number }} [opts]
71
+ * @returns {Float64Array}
72
+ */
73
+ function embedToken(token, opts = {}) {
74
+ const dim = opts.dim ?? DEFAULT_DIM;
75
+ const ngram = opts.ngram ?? DEFAULT_NGRAM;
76
+ const vec = new Float64Array(dim);
77
+ for (const gram of charNgrams(token, ngram)) {
78
+ const h = hash32(gram);
79
+ const idx = h % dim;
80
+ const sign = (h & 1) === 0 ? 1 : -1;
81
+ vec[idx] += sign;
82
+ // Second hash for denser projection (locality-sensitive bag)
83
+ const h2 = hash32(`${gram}:2`);
84
+ vec[h2 % dim] += ((h2 >> 1) & 1) === 0 ? 0.5 : -0.5;
85
+ }
86
+ // L2 normalize
87
+ let norm = 0;
88
+ for (let i = 0; i < dim; i += 1) norm += vec[i] * vec[i];
89
+ norm = Math.sqrt(norm) || 1;
90
+ for (let i = 0; i < dim; i += 1) vec[i] /= norm;
91
+ return vec;
92
+ }
93
+
94
+ /**
95
+ * Cosine similarity for unit vectors (dot product).
96
+ * @param {Float64Array|number[]} a
97
+ * @param {Float64Array|number[]} b
98
+ * @returns {number}
99
+ */
100
+ function cosine(a, b) {
101
+ const n = Math.min(a.length, b.length);
102
+ let s = 0;
103
+ for (let i = 0; i < n; i += 1) s += a[i] * b[i];
104
+ return s;
105
+ }
106
+
107
+ /**
108
+ * Encode text as a bag of token vectors (ColBERT multi-vector representation).
109
+ * @param {string} text
110
+ * @param {{ dim?: number, ngram?: number, maxTokens?: number, tokenEmbedder?: (t: string) => Float64Array|number[] }} [opts]
111
+ * @returns {{ tokens: string[], vectors: Array<Float64Array|number[]> }}
112
+ */
113
+ function encodeMultiVector(text, opts = {}) {
114
+ const maxTokens = opts.maxTokens ?? 64;
115
+ const tokens = tokenize(text).slice(0, maxTokens);
116
+ const embedder = opts.tokenEmbedder || ((t) => embedToken(t, opts));
117
+ const vectors = tokens.map((t) => embedder(t));
118
+ return { tokens, vectors };
119
+ }
120
+
121
+ /**
122
+ * ColBERT MaxSim: Σ_i max_j cos(q_i, d_j), normalized by |Q|.
123
+ * @param {Array<Float64Array|number[]>} queryVectors
124
+ * @param {Array<Float64Array|number[]>} docVectors
125
+ * @returns {number} score in [0, 1] approximately
126
+ */
127
+ function maxSim(queryVectors, docVectors) {
128
+ if (!queryVectors.length || !docVectors.length) return 0;
129
+ let total = 0;
130
+ for (const q of queryVectors) {
131
+ let best = -1;
132
+ for (const d of docVectors) {
133
+ const c = cosine(q, d);
134
+ if (c > best) best = c;
135
+ }
136
+ total += Math.max(0, best);
137
+ }
138
+ // Normalize by query length so longer queries don't dominate
139
+ return Math.min(1, total / queryVectors.length);
140
+ }
141
+
142
+ /**
143
+ * Score a (query, document) pair with ColBERT-style late interaction.
144
+ * @param {string} query
145
+ * @param {string} document
146
+ * @param {object} [opts]
147
+ * @returns {{ score: number, queryTokens: string[], docTokens: string[], mode: string }}
148
+ */
149
+ function scoreLateInteraction(query, document, opts = {}) {
150
+ const q = encodeMultiVector(query, opts);
151
+ const d = encodeMultiVector(document, opts);
152
+ const score = maxSim(q.vectors, d.vectors);
153
+ return {
154
+ score: Number(score.toFixed(6)),
155
+ queryTokens: q.tokens,
156
+ docTokens: d.tokens,
157
+ mode: opts.tokenEmbedder ? 'colbert-style-external' : 'colbert-style-hash',
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Rerank candidates by MaxSim late interaction.
163
+ * @param {string} query
164
+ * @param {Array<object>} candidates
165
+ * @param {{ topK?: number, textOf?: (c: object) => string, blendWeight?: number }} [options]
166
+ * @returns {Array<object>} candidates with maxSimScore + optional blend into rerankedScore
167
+ */
168
+ function rerankWithMaxSim(query, candidates, options = {}) {
169
+ const {
170
+ topK = 5,
171
+ textOf = defaultTextOf,
172
+ blendWeight = 0.55,
173
+ dim,
174
+ ngram,
175
+ maxTokens,
176
+ tokenEmbedder,
177
+ } = options;
178
+
179
+ if (!candidates || candidates.length === 0) return [];
180
+ if (candidates.length === 1) {
181
+ const only = candidates[0];
182
+ return [{
183
+ ...only,
184
+ maxSimScore: 1,
185
+ rerankedScore: only.rerankedScore ?? only.relevanceScore ?? 1,
186
+ lateInteractionMode: 'trivial',
187
+ }].slice(0, topK);
188
+ }
189
+
190
+ const qEnc = encodeMultiVector(query, { dim, ngram, maxTokens, tokenEmbedder });
191
+ const scored = candidates.map((c) => {
192
+ const docText = textOf(c);
193
+ const dEnc = encodeMultiVector(docText, { dim, ngram, maxTokens, tokenEmbedder });
194
+ const ms = maxSim(qEnc.vectors, dEnc.vectors);
195
+ const orig = Number(c.rerankedScore ?? c.relevanceScore ?? c.score ?? 0);
196
+ const blended = blendWeight * ms + (1 - blendWeight) * Math.max(0, Math.min(1, orig));
197
+ return {
198
+ ...c,
199
+ maxSimScore: Number(ms.toFixed(6)),
200
+ rerankedScore: Number(blended.toFixed(6)),
201
+ lateInteractionMode: tokenEmbedder ? 'colbert-style-external' : 'colbert-style-hash',
202
+ };
203
+ });
204
+
205
+ return scored
206
+ .sort((a, b) => (b.rerankedScore || 0) - (a.rerankedScore || 0))
207
+ .slice(0, topK);
208
+ }
209
+
210
+ function defaultTextOf(candidate) {
211
+ if (!candidate || typeof candidate !== 'object') return String(candidate || '');
212
+ return [
213
+ candidate.title,
214
+ candidate.whatWentWrong,
215
+ candidate.whatToChange,
216
+ candidate.howToAvoid,
217
+ candidate.summary,
218
+ candidate.content,
219
+ candidate.context,
220
+ Array.isArray(candidate.tags) ? candidate.tags.join(' ') : '',
221
+ ].filter(Boolean).join(' ');
222
+ }
223
+
224
+ module.exports = {
225
+ hash32,
226
+ tokenize,
227
+ charNgrams,
228
+ embedToken,
229
+ cosine,
230
+ encodeMultiVector,
231
+ maxSim,
232
+ scoreLateInteraction,
233
+ rerankWithMaxSim,
234
+ DEFAULT_DIM,
235
+ DEFAULT_NGRAM,
236
+ };