thumbgate 1.28.0 β 1.28.1
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 +2 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +96 -122
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +18 -33
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +70 -70
- package/config/gates/default.json +2 -2
- package/config/github-about.json +2 -5
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +18 -8
- package/public/chatgpt-app.html +2 -2
- package/public/codex-enterprise.html +1 -1
- package/public/codex-plugin.html +1 -1
- package/public/diagnostic.html +23 -1
- package/public/guide.html +1 -1
- package/public/index.html +172 -201
- package/public/numbers.html +2 -2
- package/public/partner-intake.html +198 -0
- package/public/pricing.html +51 -14
- package/scripts/billing.js +815 -137
- package/scripts/checkout-attribution-reference.js +85 -0
- package/scripts/claude-feedback-sync.js +23 -6
- package/scripts/cli-feedback.js +19 -10
- package/scripts/feedback-loop.js +290 -1
- package/scripts/feedback-quality.js +25 -26
- package/scripts/feedback-sanitizer.js +151 -3
- package/scripts/gates-engine.js +128 -49
- package/scripts/hosted-config.js +9 -2
- package/scripts/mailer/resend-mailer.js +11 -5
- package/scripts/secret-scanner.js +363 -68
- package/scripts/self-protection.js +21 -36
- package/src/api/server.js +121 -12
- package/public/assets/brand/github-social-preview.png +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Attribution survival for EXTERNAL Stripe Payment Links.
|
|
4
|
+
//
|
|
5
|
+
// The $499 diagnostic and the sprint check out via raw `buy.stripe.com` Payment
|
|
6
|
+
// Links. Payment Links DROP appended utm_* query params and carry NO session
|
|
7
|
+
// metadata β but they DO preserve `client_reference_id` into
|
|
8
|
+
// `checkout.session.completed`. Without packing attribution into that field, a
|
|
9
|
+
// marketplace-attributed paid checkout (e.g. utm_source=aiventyx) is recorded as
|
|
10
|
+
// source=unknown, so it can neither be credited to the marketplace partner nor
|
|
11
|
+
// reported. This module packs/parses a compact, URL- and Stripe-safe reference.
|
|
12
|
+
//
|
|
13
|
+
// Current format: `tg2<source><traceId><acquisitionId><planId>`, with every
|
|
14
|
+
// field encoded as a two-digit length followed by the cleaned value. The
|
|
15
|
+
// parser remains backward-compatible with the original three-field `tg1`
|
|
16
|
+
// format. Stripe accepts only [A-Za-z0-9_-], so punctuation delimiters are
|
|
17
|
+
// invalid. Length-prefixing preserves valid hyphens and underscores without
|
|
18
|
+
// introducing an unsupported delimiter and keeps the value under Stripe's
|
|
19
|
+
// 200-character limit.
|
|
20
|
+
|
|
21
|
+
const CURRENT_PREFIX = 'tg2';
|
|
22
|
+
const LEGACY_PREFIX = 'tg1';
|
|
23
|
+
const MAX_FIELD = 45;
|
|
24
|
+
|
|
25
|
+
function cleanField(value, maxLength = MAX_FIELD) {
|
|
26
|
+
return String(value == null ? '' : value)
|
|
27
|
+
.replace(/[^A-Za-z0-9_-]/g, '')
|
|
28
|
+
.slice(0, maxLength);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function encodeField(value) {
|
|
32
|
+
const field = cleanField(value);
|
|
33
|
+
return `${String(field.length).padStart(2, '0')}${field}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build a Stripe `client_reference_id` from checkout metadata. Returns null when
|
|
37
|
+
// there is no attributable source (so callers append nothing).
|
|
38
|
+
function packCheckoutReference(metadata = {}) {
|
|
39
|
+
const source = cleanField(metadata.utmSource || metadata.source);
|
|
40
|
+
if (!source) return null;
|
|
41
|
+
const traceId = cleanField(metadata.traceId);
|
|
42
|
+
const acquisitionId = cleanField(metadata.acquisitionId);
|
|
43
|
+
const planId = cleanField(metadata.planId || metadata.plan_id);
|
|
44
|
+
return `${CURRENT_PREFIX}${encodeField(source)}${encodeField(traceId)}${encodeField(acquisitionId)}${encodeField(planId)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse a `client_reference_id` produced by packCheckoutReference. Returns null
|
|
48
|
+
// for anything that is not a recognized tg1 reference with a real source.
|
|
49
|
+
function parseCheckoutReference(clientReferenceId) {
|
|
50
|
+
const raw = String(clientReferenceId == null ? '' : clientReferenceId);
|
|
51
|
+
const prefix = raw.startsWith(CURRENT_PREFIX)
|
|
52
|
+
? CURRENT_PREFIX
|
|
53
|
+
: raw.startsWith(LEGACY_PREFIX)
|
|
54
|
+
? LEGACY_PREFIX
|
|
55
|
+
: null;
|
|
56
|
+
if (!prefix) return null;
|
|
57
|
+
const fieldCount = prefix === CURRENT_PREFIX ? 4 : 3;
|
|
58
|
+
const maxFieldLength = prefix === CURRENT_PREFIX ? MAX_FIELD : 60;
|
|
59
|
+
|
|
60
|
+
const fields = [];
|
|
61
|
+
let cursor = prefix.length;
|
|
62
|
+
for (let index = 0; index < fieldCount; index += 1) {
|
|
63
|
+
const lengthToken = raw.slice(cursor, cursor + 2);
|
|
64
|
+
if (!/^\d{2}$/.test(lengthToken)) return null;
|
|
65
|
+
const fieldLength = Number(lengthToken);
|
|
66
|
+
if (fieldLength > maxFieldLength) return null;
|
|
67
|
+
cursor += 2;
|
|
68
|
+
const field = raw.slice(cursor, cursor + fieldLength);
|
|
69
|
+
if (field.length !== fieldLength || cleanField(field, maxFieldLength) !== field) return null;
|
|
70
|
+
fields.push(field);
|
|
71
|
+
cursor += fieldLength;
|
|
72
|
+
}
|
|
73
|
+
if (cursor !== raw.length) return null;
|
|
74
|
+
|
|
75
|
+
const [source, traceId, acquisitionId, planId] = fields;
|
|
76
|
+
if (!source) return null;
|
|
77
|
+
return {
|
|
78
|
+
source,
|
|
79
|
+
traceId: traceId || null,
|
|
80
|
+
acquisitionId: acquisitionId || null,
|
|
81
|
+
planId: planId || null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { packCheckoutReference, parseCheckoutReference };
|
|
@@ -5,11 +5,12 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const {
|
|
7
7
|
captureFeedback,
|
|
8
|
+
buildFeedbackSourceIdentity,
|
|
8
9
|
getFeedbackPaths,
|
|
9
10
|
readJSONL,
|
|
10
11
|
analyzeFeedback,
|
|
11
12
|
} = require('./feedback-loop');
|
|
12
|
-
const { normalizeFeedbackText } = require('./feedback-quality');
|
|
13
|
+
const { detectFeedbackSignal, normalizeFeedbackText } = require('./feedback-quality');
|
|
13
14
|
const {
|
|
14
15
|
resolveFeedbackDir,
|
|
15
16
|
resolveProjectDir,
|
|
@@ -126,10 +127,8 @@ function parseHistoryTimestamp(value) {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
function detectSignal(text) {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (/(thumbs?\s*up|that worked|looks good|nice work|perfect|good job)/i.test(normalized)) return 'up';
|
|
132
|
-
return null;
|
|
130
|
+
const detected = detectFeedbackSignal(text);
|
|
131
|
+
return detected ? detected.signal : null;
|
|
133
132
|
}
|
|
134
133
|
|
|
135
134
|
function extractPromptText(entry) {
|
|
@@ -179,6 +178,9 @@ function toHistoryCandidate(entry, options = {}) {
|
|
|
179
178
|
promptText,
|
|
180
179
|
signal,
|
|
181
180
|
timestampMs: parseHistoryTimestamp(entry.timestamp),
|
|
181
|
+
sessionId: entry.sessionId || entry.session_id || null,
|
|
182
|
+
promptId: entry.promptId || entry.prompt_id || null,
|
|
183
|
+
projectDir: entry.project || entry.cwd || null,
|
|
182
184
|
};
|
|
183
185
|
}
|
|
184
186
|
|
|
@@ -261,15 +263,30 @@ function syncClaudeHistoryFeedback(options = {}) {
|
|
|
261
263
|
continue;
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
const sourceEvent = buildFeedbackSourceIdentity({
|
|
267
|
+
signal: candidate.signal,
|
|
268
|
+
promptText: candidate.promptText,
|
|
269
|
+
sessionId: candidate.sessionId,
|
|
270
|
+
promptId: candidate.promptId,
|
|
271
|
+
projectDir: candidate.projectDir,
|
|
272
|
+
timestamp: candidate.timestampMs,
|
|
273
|
+
source: 'claude-history',
|
|
274
|
+
});
|
|
264
275
|
const captureResult = captureFeedback({
|
|
265
276
|
signal: candidate.signal,
|
|
266
277
|
context: candidate.promptText,
|
|
267
278
|
whatWentWrong: candidate.signal === 'down' ? candidate.promptText : undefined,
|
|
268
279
|
whatWorked: candidate.signal === 'up' ? candidate.promptText : undefined,
|
|
269
280
|
tags: ['claude-history-sync', 'auto-capture-fallback'],
|
|
281
|
+
sourceEvent,
|
|
270
282
|
});
|
|
271
283
|
|
|
272
|
-
if (captureResult
|
|
284
|
+
if (captureResult?.duplicate) {
|
|
285
|
+
processedIds.add(candidate.externalId);
|
|
286
|
+
skippedCount += 1;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (captureResult?.feedbackEvent) {
|
|
273
290
|
existingEntries.push(captureResult.feedbackEvent);
|
|
274
291
|
}
|
|
275
292
|
processedIds.add(candidate.externalId);
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -44,9 +44,10 @@ const RST = '\x1b[0m';
|
|
|
44
44
|
* @param {Array} [opts.chatHistory] - Conversation messages for distillation
|
|
45
45
|
* @param {string} [opts.whatWentWrong] - For thumbs down
|
|
46
46
|
* @param {string} [opts.whatWorked] - For thumbs up
|
|
47
|
+
* @param {Object} [opts.sourceEvent] - Hashed source identity for idempotent hook capture
|
|
47
48
|
* @returns {Object} Result with feedback + lesson + stats
|
|
48
49
|
*/
|
|
49
|
-
function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, whatWorked } = {}) {
|
|
50
|
+
function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, whatWorked, sourceEvent } = {}) {
|
|
50
51
|
const isDown = signal === 'down' || signal === 'negative';
|
|
51
52
|
|
|
52
53
|
// 1. Capture the feedback
|
|
@@ -57,6 +58,7 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
|
|
|
57
58
|
context: context || (isDown ? 'Thumbs down from CLI' : 'Thumbs up from CLI'),
|
|
58
59
|
whatWentWrong: whatWentWrong || undefined,
|
|
59
60
|
whatWorked: whatWorked || undefined,
|
|
61
|
+
sourceEvent,
|
|
60
62
|
});
|
|
61
63
|
} catch (err) {
|
|
62
64
|
feedbackResult = { accepted: false, reason: err.message };
|
|
@@ -90,30 +92,37 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
|
|
|
90
92
|
*/
|
|
91
93
|
function formatCliOutput(result) {
|
|
92
94
|
const lines = [];
|
|
93
|
-
const
|
|
95
|
+
const feedbackResult = result.feedbackResult;
|
|
96
|
+
const feedbackSignal = feedbackResult?.signal || feedbackResult?.feedbackEvent?.signal;
|
|
94
97
|
const isDown = ['down', 'negative', 'thumbs_down'].includes(feedbackSignal);
|
|
95
98
|
|
|
96
99
|
// Header
|
|
97
|
-
if (
|
|
100
|
+
if (feedbackResult?.duplicate) {
|
|
101
|
+
lines.push(`${Y}${BD}Feedback already captured${RST}`);
|
|
102
|
+
const feedbackId = feedbackResult.feedbackEvent?.id;
|
|
103
|
+
const memoryId = feedbackResult.memoryRecord?.id;
|
|
104
|
+
if (feedbackId) lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
105
|
+
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
106
|
+
} else if (feedbackResult && feedbackResult.accepted !== false) {
|
|
98
107
|
lines.push(`${isDown ? R : G}${BD}${isDown ? 'π Thumbs down recorded' : 'π Thumbs up recorded'}${RST}`);
|
|
99
|
-
const feedbackId =
|
|
100
|
-
const memoryId =
|
|
108
|
+
const feedbackId = feedbackResult.feedbackEvent?.id || feedbackResult.id;
|
|
109
|
+
const memoryId = feedbackResult.memoryRecord?.id || feedbackResult.memoryId;
|
|
101
110
|
if (feedbackId) {
|
|
102
111
|
lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
103
112
|
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
104
113
|
// Echo feedback ID to stderr so it's visible directly in the terminal,
|
|
105
114
|
// not hidden behind Claude Code's "ctrl+o to expand" MCP call collapse.
|
|
106
|
-
|
|
115
|
+
const capturedIds = memoryId ? `${feedbackId}, ${memoryId}` : feedbackId;
|
|
116
|
+
process.stderr.write(`β
Feedback captured (${capturedIds})\n`);
|
|
107
117
|
}
|
|
108
118
|
} else {
|
|
109
|
-
lines.push(`${R}Feedback not accepted: ${
|
|
119
|
+
lines.push(`${R}Feedback not accepted: ${feedbackResult?.reason || 'unknown'}${RST}`);
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
// Explicit-directive offer (e.g. "never β¦" β offer immediate force-gate).
|
|
113
|
-
if (result.forceGateHint
|
|
123
|
+
if (result.forceGateHint?.message) {
|
|
114
124
|
const color = result.forceGateHint.kind === 'force-gate-offer' ? Y : D;
|
|
115
|
-
lines.push('');
|
|
116
|
-
lines.push(`${color}π‘ ${result.forceGateHint.message}${RST}`);
|
|
125
|
+
lines.push('', `${color}π‘ ${result.forceGateHint.message}${RST}`);
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
// Distilled lesson (if thumbs down)
|
package/scripts/feedback-loop.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* -> compute analytics -> generate prevention rules
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
const crypto = require('node:crypto');
|
|
10
11
|
const fs = require('fs');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
@@ -19,6 +20,7 @@ const {
|
|
|
19
20
|
const {
|
|
20
21
|
buildClarificationMessage,
|
|
21
22
|
isGenericFeedbackText,
|
|
23
|
+
normalizeFeedbackText,
|
|
22
24
|
} = require('./feedback-quality');
|
|
23
25
|
const {
|
|
24
26
|
buildRubricEvaluation,
|
|
@@ -55,6 +57,10 @@ const {
|
|
|
55
57
|
}));
|
|
56
58
|
|
|
57
59
|
const AUDIT_TRAIL_TAG = 'audit-trail';
|
|
60
|
+
const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 30 * 1000;
|
|
61
|
+
const FEEDBACK_EVENT_CLAIM_STALE_MS = 60 * 1000;
|
|
62
|
+
const FEEDBACK_EVENT_CLAIM_WAIT_MS = 15 * 1000;
|
|
63
|
+
const FEEDBACK_EVENT_CLAIM_POLL_MS = 25;
|
|
58
64
|
|
|
59
65
|
/**
|
|
60
66
|
* Anonymous fire-and-forget CLI feedback telemetry.
|
|
@@ -631,6 +637,269 @@ function normalizeSignal(signal) {
|
|
|
631
637
|
return null;
|
|
632
638
|
}
|
|
633
639
|
|
|
640
|
+
function hashFeedbackSourcePart(value) {
|
|
641
|
+
const text = String(value || '').trim();
|
|
642
|
+
if (!text) return null;
|
|
643
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function parseFeedbackSourceTimestamp(value) {
|
|
647
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
648
|
+
return value > 1e12 ? value : value * 1000;
|
|
649
|
+
}
|
|
650
|
+
const text = String(value || '').trim();
|
|
651
|
+
if (!text) return Date.now();
|
|
652
|
+
if (/^\d+$/.test(text)) {
|
|
653
|
+
const numeric = Number(text);
|
|
654
|
+
return numeric > 1e12 ? numeric : numeric * 1000;
|
|
655
|
+
}
|
|
656
|
+
const parsed = Date.parse(text);
|
|
657
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Build a privacy-preserving identity for one external thumbs event.
|
|
662
|
+
* Raw session, prompt, and project identifiers are hashed before persistence.
|
|
663
|
+
* A prompt ID wins when present so two distinct prompts with identical text are
|
|
664
|
+
* still separate events; otherwise the session + project + text fingerprint is
|
|
665
|
+
* deduplicated inside a short capture window.
|
|
666
|
+
*/
|
|
667
|
+
function buildFeedbackSourceIdentity(input = {}) {
|
|
668
|
+
const signal = normalizeSignal(input.signal);
|
|
669
|
+
const normalizedText = normalizeFeedbackText(input.promptText || input.context);
|
|
670
|
+
if (!signal || !normalizedText) return null;
|
|
671
|
+
|
|
672
|
+
const sessionHash = hashFeedbackSourcePart(input.sessionId);
|
|
673
|
+
const promptIdHash = hashFeedbackSourcePart(input.promptId);
|
|
674
|
+
const projectHash = hashFeedbackSourcePart(input.projectDir || input.project);
|
|
675
|
+
if (!sessionHash && !promptIdHash) return null;
|
|
676
|
+
|
|
677
|
+
const textHash = hashFeedbackSourcePart(normalizedText);
|
|
678
|
+
const discriminator = promptIdHash
|
|
679
|
+
? `prompt:${sessionHash || ''}:${projectHash || ''}:${promptIdHash}`
|
|
680
|
+
: `content:${sessionHash || ''}:${projectHash || ''}:${textHash}`;
|
|
681
|
+
const eventHash = hashFeedbackSourcePart(['v1', signal, discriminator].join(':'));
|
|
682
|
+
const key = `fev_${eventHash}`;
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
key,
|
|
686
|
+
signal,
|
|
687
|
+
normalizedText,
|
|
688
|
+
sessionHash,
|
|
689
|
+
promptIdHash,
|
|
690
|
+
projectHash,
|
|
691
|
+
eventTimestampMs: parseFeedbackSourceTimestamp(input.timestamp),
|
|
692
|
+
source: String(input.source || 'external').replace(/[^a-z0-9._-]/gi, '-').slice(0, 64),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizeFeedbackSourceIdentity(identity, params = {}) {
|
|
697
|
+
if (!identity || typeof identity !== 'object' || typeof identity.key !== 'string') return null;
|
|
698
|
+
const signal = normalizeSignal(params.signal || identity.signal);
|
|
699
|
+
const normalizedText = normalizeFeedbackText(
|
|
700
|
+
identity.normalizedText || params.context || params.whatWentWrong || params.whatWorked,
|
|
701
|
+
);
|
|
702
|
+
if (!signal || !normalizedText) return null;
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
key: `fev_${hashFeedbackSourcePart(identity.key)}`,
|
|
706
|
+
signal,
|
|
707
|
+
normalizedText,
|
|
708
|
+
sessionHash: hashFeedbackSourcePart(identity.sessionHash),
|
|
709
|
+
promptIdHash: hashFeedbackSourcePart(identity.promptIdHash),
|
|
710
|
+
projectHash: hashFeedbackSourcePart(identity.projectHash),
|
|
711
|
+
eventTimestampMs: parseFeedbackSourceTimestamp(identity.eventTimestampMs),
|
|
712
|
+
source: String(identity.source || 'external').replace(/[^a-z0-9._-]/gi, '-').slice(0, 64),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function publicFeedbackSourceMetadata(identity) {
|
|
717
|
+
if (!identity) return null;
|
|
718
|
+
return {
|
|
719
|
+
key: identity.key,
|
|
720
|
+
sessionHash: identity.sessionHash,
|
|
721
|
+
promptIdHash: identity.promptIdHash,
|
|
722
|
+
projectHash: identity.projectHash,
|
|
723
|
+
source: identity.source,
|
|
724
|
+
timestamp: new Date(identity.eventTimestampMs).toISOString(),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function readClaimState(claimPath) {
|
|
729
|
+
try {
|
|
730
|
+
return JSON.parse(fs.readFileSync(path.join(claimPath, 'state.json'), 'utf8'));
|
|
731
|
+
} catch {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function writeClaimState(claimPath, state) {
|
|
737
|
+
const target = path.join(claimPath, 'state.json');
|
|
738
|
+
const temporary = path.join(claimPath, `state.${process.pid}.${Date.now()}.tmp`);
|
|
739
|
+
fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
740
|
+
fs.renameSync(temporary, target);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function sleepSync(milliseconds) {
|
|
744
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
745
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function replaceStaleClaim(claimPath) {
|
|
749
|
+
const nonce = crypto.randomBytes(4).toString('hex');
|
|
750
|
+
const stalePath = `${claimPath}.stale-${process.pid}-${Date.now()}-${nonce}`;
|
|
751
|
+
try {
|
|
752
|
+
fs.renameSync(claimPath, stalePath);
|
|
753
|
+
fs.rmSync(stalePath, { recursive: true, force: true });
|
|
754
|
+
return true;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if (error && (error.code === 'ENOENT' || error.code === 'EEXIST')) return false;
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function waitForClaimCompletion(claimPath, deadlineMs) {
|
|
762
|
+
let state = readClaimState(claimPath);
|
|
763
|
+
while ((!state || state.status === 'pending') && Date.now() < deadlineMs) {
|
|
764
|
+
sleepSync(FEEDBACK_EVENT_CLAIM_POLL_MS);
|
|
765
|
+
state = readClaimState(claimPath);
|
|
766
|
+
}
|
|
767
|
+
return state;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function findRecordById(filePath, id) {
|
|
771
|
+
if (!id) return null;
|
|
772
|
+
const entries = readJSONL(filePath, { maxLines: 500 });
|
|
773
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
774
|
+
if (entries[index]?.id === id) return entries[index];
|
|
775
|
+
}
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function duplicateCaptureResult(receipt, paths, options = {}) {
|
|
780
|
+
const feedbackEvent = findRecordById(paths.FEEDBACK_LOG_PATH, receipt?.feedbackId);
|
|
781
|
+
const memoryRecord = findRecordById(paths.MEMORY_LOG_PATH, receipt?.memoryId);
|
|
782
|
+
return {
|
|
783
|
+
accepted: Boolean(receipt?.accepted),
|
|
784
|
+
signalLogged: Boolean(receipt?.signalLogged),
|
|
785
|
+
duplicate: true,
|
|
786
|
+
pending: Boolean(options.pending),
|
|
787
|
+
status: options.pending ? 'duplicate_in_progress' : 'duplicate',
|
|
788
|
+
reason: options.pending ? 'source_event_capture_in_progress' : 'source_event_already_captured',
|
|
789
|
+
message: options.pending
|
|
790
|
+
? 'Equivalent feedback is already being captured.'
|
|
791
|
+
: 'This feedback event was already captured; no counters or lessons were changed.',
|
|
792
|
+
feedbackEvent: feedbackEvent || (receipt?.feedbackId ? { id: receipt.feedbackId } : null),
|
|
793
|
+
memoryRecord: memoryRecord || (receipt?.memoryId ? { id: receipt.memoryId } : null),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function sourceHashMatches(expected, actual) {
|
|
798
|
+
return !expected || !actual || expected === actual;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isDuplicateFeedbackEntry(entry, identity) {
|
|
802
|
+
if (!entry || entry.signal !== identity.signal) return false;
|
|
803
|
+
if (normalizeFeedbackText(entry.submittedContext || entry.context) !== identity.normalizedText) return false;
|
|
804
|
+
|
|
805
|
+
const sourceEvent = entry.sourceEvent || {};
|
|
806
|
+
if (!sourceHashMatches(identity.sessionHash, sourceEvent.sessionHash)) return false;
|
|
807
|
+
if (!sourceHashMatches(identity.projectHash, sourceEvent.projectHash)) return false;
|
|
808
|
+
if (!sourceHashMatches(identity.promptIdHash, sourceEvent.promptIdHash)) return false;
|
|
809
|
+
if (identity.promptIdHash && sourceEvent.promptIdHash === identity.promptIdHash) return true;
|
|
810
|
+
|
|
811
|
+
const timestamp = parseFeedbackSourceTimestamp(sourceEvent.timestamp || entry.timestamp);
|
|
812
|
+
return Math.abs(identity.eventTimestampMs - timestamp) <= FEEDBACK_EVENT_DEDUPE_WINDOW_MS;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function receiptFromFeedbackEntry(entry, paths) {
|
|
816
|
+
if (!entry) return null;
|
|
817
|
+
const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 500 });
|
|
818
|
+
const memory = memories.find((candidate) => candidate?.sourceFeedbackId === entry.id);
|
|
819
|
+
return {
|
|
820
|
+
status: 'complete',
|
|
821
|
+
feedbackId: entry.id,
|
|
822
|
+
memoryId: memory?.id,
|
|
823
|
+
accepted: entry.actionType !== 'no-action'
|
|
824
|
+
&& (!Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0),
|
|
825
|
+
signalLogged: true,
|
|
826
|
+
completedAtMs: Date.parse(entry.timestamp || '') || Date.now(),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function recentDuplicateReceipt(identity, paths) {
|
|
831
|
+
const entries = readJSONL(paths.FEEDBACK_LOG_PATH, { maxLines: 250 });
|
|
832
|
+
const entry = entries.findLast((candidate) => isDuplicateFeedbackEntry(candidate, identity));
|
|
833
|
+
return receiptFromFeedbackEntry(entry, paths);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function tryCreateFeedbackClaim(claimPath) {
|
|
837
|
+
try {
|
|
838
|
+
fs.mkdirSync(claimPath, { mode: 0o700 });
|
|
839
|
+
writeClaimState(claimPath, { status: 'pending', startedAtMs: Date.now() });
|
|
840
|
+
return { owned: true, claimPath };
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (error?.code === 'EEXIST') return null;
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function existingClaimResult(state, identity, paths) {
|
|
848
|
+
const stateTimestamp = Number(state?.completedAtMs || state?.startedAtMs || 0);
|
|
849
|
+
const ageMs = stateTimestamp > 0 ? Date.now() - stateTimestamp : 0;
|
|
850
|
+
if (state?.status === 'complete'
|
|
851
|
+
&& (identity.promptIdHash || ageMs <= FEEDBACK_EVENT_DEDUPE_WINDOW_MS)) {
|
|
852
|
+
return duplicateCaptureResult(state, paths);
|
|
853
|
+
}
|
|
854
|
+
if (state?.status === 'pending' && ageMs <= FEEDBACK_EVENT_CLAIM_STALE_MS) {
|
|
855
|
+
return duplicateCaptureResult(state, paths, { pending: true });
|
|
856
|
+
}
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function acquireFeedbackEventClaim(identity, paths) {
|
|
861
|
+
const recent = recentDuplicateReceipt(identity, paths);
|
|
862
|
+
if (recent) return { owned: false, result: duplicateCaptureResult(recent, paths) };
|
|
863
|
+
|
|
864
|
+
const claimsRoot = path.join(paths.FEEDBACK_DIR, '.feedback-event-claims');
|
|
865
|
+
const claimPath = path.join(claimsRoot, identity.key);
|
|
866
|
+
ensureDir(claimsRoot);
|
|
867
|
+
|
|
868
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
869
|
+
const ownedClaim = tryCreateFeedbackClaim(claimPath);
|
|
870
|
+
if (ownedClaim) return ownedClaim;
|
|
871
|
+
|
|
872
|
+
const state = waitForClaimCompletion(claimPath, Date.now() + FEEDBACK_EVENT_CLAIM_WAIT_MS);
|
|
873
|
+
const duplicateResult = existingClaimResult(state, identity, paths);
|
|
874
|
+
if (duplicateResult) return { owned: false, result: duplicateResult };
|
|
875
|
+
if (!replaceStaleClaim(claimPath)) continue;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
owned: false,
|
|
880
|
+
result: duplicateCaptureResult(null, paths, { pending: true }),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function finalizeFeedbackEventClaim(claim, result) {
|
|
885
|
+
if (!claim?.owned) return;
|
|
886
|
+
writeClaimState(claim.claimPath, {
|
|
887
|
+
status: 'complete',
|
|
888
|
+
startedAtMs: Date.now(),
|
|
889
|
+
completedAtMs: Date.now(),
|
|
890
|
+
accepted: Boolean(result?.accepted),
|
|
891
|
+
signalLogged: Boolean(result?.signalLogged || result?.feedbackEvent),
|
|
892
|
+
feedbackId: result?.feedbackEvent?.id || null,
|
|
893
|
+
memoryId: result?.memoryRecord?.id || null,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function releaseFeedbackEventClaim(claim) {
|
|
898
|
+
if (claim?.owned && claim.claimPath) {
|
|
899
|
+
fs.rmSync(claim.claimPath, { recursive: true, force: true });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
634
903
|
function parseOptionalObject(input, name) {
|
|
635
904
|
if (input == null) return {};
|
|
636
905
|
if (typeof input === 'object' && !Array.isArray(input)) return input;
|
|
@@ -1144,6 +1413,7 @@ function captureFeedback(params) {
|
|
|
1144
1413
|
structuredRule: structuredRule || null,
|
|
1145
1414
|
...(reflection && { reflection }),
|
|
1146
1415
|
gateAction: params.gateAction || null,
|
|
1416
|
+
sourceEvent: publicFeedbackSourceMetadata(params.sourceEvent),
|
|
1147
1417
|
timestamp: now,
|
|
1148
1418
|
};
|
|
1149
1419
|
|
|
@@ -1567,6 +1837,24 @@ function captureFeedback(params) {
|
|
|
1567
1837
|
return result;
|
|
1568
1838
|
}
|
|
1569
1839
|
|
|
1840
|
+
function captureFeedbackIdempotent(params = {}) {
|
|
1841
|
+
const paths = getFeedbackPaths();
|
|
1842
|
+
const identity = normalizeFeedbackSourceIdentity(params.sourceEvent, params);
|
|
1843
|
+
if (!identity) return captureFeedback(params);
|
|
1844
|
+
|
|
1845
|
+
const claim = acquireFeedbackEventClaim(identity, paths);
|
|
1846
|
+
if (!claim.owned) return claim.result;
|
|
1847
|
+
|
|
1848
|
+
try {
|
|
1849
|
+
const result = captureFeedback({ ...params, sourceEvent: identity });
|
|
1850
|
+
finalizeFeedbackEventClaim(claim, result);
|
|
1851
|
+
return result;
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
releaseFeedbackEventClaim(claim);
|
|
1854
|
+
throw error;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1570
1858
|
function analyzeFeedback(logPath) {
|
|
1571
1859
|
const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
|
|
1572
1860
|
const resolvedLogPath = logPath || FEEDBACK_LOG_PATH;
|
|
@@ -2200,7 +2488,8 @@ function buildCorrectiveActionsReminder(correctiveActions = []) {
|
|
|
2200
2488
|
}
|
|
2201
2489
|
|
|
2202
2490
|
module.exports = {
|
|
2203
|
-
captureFeedback,
|
|
2491
|
+
captureFeedback: captureFeedbackIdempotent,
|
|
2492
|
+
buildFeedbackSourceIdentity,
|
|
2204
2493
|
compactMemories,
|
|
2205
2494
|
buildCorrectiveActionsReminder,
|
|
2206
2495
|
analyzeFeedback,
|
|
@@ -100,49 +100,48 @@ function isNearDownToken(token) {
|
|
|
100
100
|
|
|
101
101
|
function detectFeedbackSignal(value) {
|
|
102
102
|
const raw = String(value || '');
|
|
103
|
-
|
|
104
|
-
if (
|
|
103
|
+
const leading = raw.trimStart();
|
|
104
|
+
if (/^["'`]/.test(leading)) return null;
|
|
105
|
+
if (/^π(?:π»|πΌ|π½|πΎ|πΏ)?/u.test(leading)) return { signal: 'down', confidence: 'emoji', match: 'π' };
|
|
106
|
+
if (/^π(?:π»|πΌ|π½|πΎ|πΏ)?/u.test(leading)) return { signal: 'up', confidence: 'emoji', match: 'π' };
|
|
105
107
|
|
|
106
108
|
const normalized = normalizeFeedbackText(raw);
|
|
107
109
|
if (!normalized) return null;
|
|
108
110
|
|
|
109
111
|
const exactDown = [
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
/\bfix this\b/,
|
|
112
|
+
/^thumbs?\s*down\b/,
|
|
113
|
+
/^thumbs?down\b/,
|
|
114
|
+
/^that failed\b/,
|
|
115
|
+
/^it failed\b/,
|
|
116
|
+
/^that was wrong\b/,
|
|
116
117
|
];
|
|
117
118
|
if (exactDown.some((pattern) => pattern.test(normalized))) {
|
|
118
119
|
return { signal: 'down', confidence: 'exact', match: normalized };
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
const exactUp = [
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
/^thumbs?\s*up\b/,
|
|
124
|
+
/^thumbs?up\b/,
|
|
125
|
+
/^that worked\b/,
|
|
126
|
+
/^it worked\b/,
|
|
127
|
+
/^looks good\b/,
|
|
128
|
+
/^good job\b/,
|
|
129
|
+
/^good work\b/,
|
|
130
|
+
/^nice work\b/,
|
|
131
|
+
/^perfect\b/,
|
|
132
|
+
/^lgtm\b/,
|
|
132
133
|
];
|
|
133
134
|
if (exactUp.some((pattern) => pattern.test(normalized))) {
|
|
134
135
|
return { signal: 'up', confidence: 'exact', match: normalized };
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
const words = normalized.split(/\s+/).filter(Boolean);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return { signal: 'up', confidence: 'fuzzy', match: `${words[i]} ${words[i + 1]}` };
|
|
145
|
-
}
|
|
139
|
+
if (!isNearThumbToken(words[0])) return null;
|
|
140
|
+
if (isNearDownToken(words[1])) {
|
|
141
|
+
return { signal: 'down', confidence: 'fuzzy', match: `${words[0]} ${words[1]}` };
|
|
142
|
+
}
|
|
143
|
+
if (isNearUpToken(words[1])) {
|
|
144
|
+
return { signal: 'up', confidence: 'fuzzy', match: `${words[0]} ${words[1]}` };
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
return null;
|