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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +143 -14
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +154 -36
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +215 -185
- package/config/model-tiers.json +7 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/glama.json +6 -0
- package/package.json +94 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +22 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +40 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +143 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/public/whitepaper.html +189 -0
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +359 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +359 -189
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +98 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +375 -32
- package/scripts/lesson-search.js +48 -11
- package/scripts/llm-client.js +304 -15
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/model-tier-router.js +593 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/published-cli.js +11 -1
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +95 -1
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 <
|
|
173
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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'),
|
|
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,
|
|
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,
|
package/scripts/billing.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 {
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
+
};
|