thumbgate 1.28.0 → 1.28.2
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 +54 -34
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +100 -77
- package/config/gates/default.json +2 -2
- package/config/github-about.json +2 -5
- package/config/mcp-allowlists.json +5 -0
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +19 -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/auto-wire-hooks.js +58 -2
- 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 +20 -10
- package/scripts/feedback-history-distiller.js +385 -0
- 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,8 @@ 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
|
+
chatHistory,
|
|
62
|
+
sourceEvent,
|
|
60
63
|
});
|
|
61
64
|
} catch (err) {
|
|
62
65
|
feedbackResult = { accepted: false, reason: err.message };
|
|
@@ -90,30 +93,37 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
|
|
|
90
93
|
*/
|
|
91
94
|
function formatCliOutput(result) {
|
|
92
95
|
const lines = [];
|
|
93
|
-
const
|
|
96
|
+
const feedbackResult = result.feedbackResult;
|
|
97
|
+
const feedbackSignal = feedbackResult?.signal || feedbackResult?.feedbackEvent?.signal;
|
|
94
98
|
const isDown = ['down', 'negative', 'thumbs_down'].includes(feedbackSignal);
|
|
95
99
|
|
|
96
100
|
// Header
|
|
97
|
-
if (
|
|
101
|
+
if (feedbackResult?.duplicate) {
|
|
102
|
+
lines.push(`${Y}${BD}Feedback already captured${RST}`);
|
|
103
|
+
const feedbackId = feedbackResult.feedbackEvent?.id;
|
|
104
|
+
const memoryId = feedbackResult.memoryRecord?.id;
|
|
105
|
+
if (feedbackId) lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
106
|
+
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
107
|
+
} else if (feedbackResult && feedbackResult.accepted !== false) {
|
|
98
108
|
lines.push(`${isDown ? R : G}${BD}${isDown ? '👎 Thumbs down recorded' : '👍 Thumbs up recorded'}${RST}`);
|
|
99
|
-
const feedbackId =
|
|
100
|
-
const memoryId =
|
|
109
|
+
const feedbackId = feedbackResult.feedbackEvent?.id || feedbackResult.id;
|
|
110
|
+
const memoryId = feedbackResult.memoryRecord?.id || feedbackResult.memoryId;
|
|
101
111
|
if (feedbackId) {
|
|
102
112
|
lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
103
113
|
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
104
114
|
// Echo feedback ID to stderr so it's visible directly in the terminal,
|
|
105
115
|
// not hidden behind Claude Code's "ctrl+o to expand" MCP call collapse.
|
|
106
|
-
|
|
116
|
+
const capturedIds = memoryId ? `${feedbackId}, ${memoryId}` : feedbackId;
|
|
117
|
+
process.stderr.write(`✅ Feedback captured (${capturedIds})\n`);
|
|
107
118
|
}
|
|
108
119
|
} else {
|
|
109
|
-
lines.push(`${R}Feedback not accepted: ${
|
|
120
|
+
lines.push(`${R}Feedback not accepted: ${feedbackResult?.reason || 'unknown'}${RST}`);
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
// Explicit-directive offer (e.g. "never …" → offer immediate force-gate).
|
|
113
|
-
if (result.forceGateHint
|
|
124
|
+
if (result.forceGateHint?.message) {
|
|
114
125
|
const color = result.forceGateHint.kind === 'force-gate-offer' ? Y : D;
|
|
115
|
-
lines.push('');
|
|
116
|
-
lines.push(`${color}💡 ${result.forceGateHint.message}${RST}`);
|
|
126
|
+
lines.push('', `${color}💡 ${result.forceGateHint.message}${RST}`);
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
// Distilled lesson (if thumbs down)
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { resolveFeedbackDir: resolveSharedFeedbackDir } = require('./feedback-paths');
|
|
6
|
+
const { readJsonlTail } = require('./fs-utils');
|
|
7
|
+
const { redactSecretsDeep } = require('./secret-redaction');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HISTORY_LIMIT = 10;
|
|
10
|
+
|
|
11
|
+
const CORRECTION_PATTERNS = [
|
|
12
|
+
/\bdon['’]?t\b/i,
|
|
13
|
+
/\bdo not\b/i,
|
|
14
|
+
/\bnever\b/i,
|
|
15
|
+
/\bavoid\b/i,
|
|
16
|
+
/\bstop\b/i,
|
|
17
|
+
/\bmust\b/i,
|
|
18
|
+
/\bshould\b/i,
|
|
19
|
+
/\bneed to\b/i,
|
|
20
|
+
/\buse\b/i,
|
|
21
|
+
/\brun tests?\b/i,
|
|
22
|
+
/\binclude\b/i,
|
|
23
|
+
/\bprove\b/i,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const FAILURE_PATTERNS = [
|
|
27
|
+
/\bfailed\b/i,
|
|
28
|
+
/\bbroke\b/i,
|
|
29
|
+
/\berror\b/i,
|
|
30
|
+
/\bwrong\b/i,
|
|
31
|
+
/\bignored\b/i,
|
|
32
|
+
/\bskipped\b/i,
|
|
33
|
+
/\bhallucinat/i,
|
|
34
|
+
/\bclaimed done\b/i,
|
|
35
|
+
/\bwithout proof\b/i,
|
|
36
|
+
/\bwithout evidence\b/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const SUCCESS_PATTERNS = [
|
|
40
|
+
/\bworked\b/i,
|
|
41
|
+
/\bpassed\b/i,
|
|
42
|
+
/\bverified\b/i,
|
|
43
|
+
/\bproof\b/i,
|
|
44
|
+
/\bevidence\b/i,
|
|
45
|
+
/\btests?\b/i,
|
|
46
|
+
/\bfixed\b/i,
|
|
47
|
+
/\bsuccess/i,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function normalizeText(value) {
|
|
51
|
+
return String(value || '')
|
|
52
|
+
.replace(/\s+/g, ' ')
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function truncate(value, max = 180) {
|
|
57
|
+
const text = normalizeText(value);
|
|
58
|
+
if (text.length <= max) return text;
|
|
59
|
+
return `${text.slice(0, max - 1).trim()}…`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function appendJsonl(filePath, record) {
|
|
63
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
64
|
+
// Redact secrets before any conversation turn touches disk — conversation-window.jsonl was the
|
|
65
|
+
// 2026-06-10 sk_live_ leak vector. See scripts/secret-redaction.js.
|
|
66
|
+
fs.appendFileSync(filePath, `${JSON.stringify(redactSecretsDeep(record))}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveFeedbackDir(feedbackDir) {
|
|
70
|
+
if (feedbackDir) {
|
|
71
|
+
return resolveSharedFeedbackDir({ feedbackDir });
|
|
72
|
+
}
|
|
73
|
+
const env = { ...process.env };
|
|
74
|
+
delete env.INIT_CWD;
|
|
75
|
+
delete env.PWD;
|
|
76
|
+
return resolveSharedFeedbackDir({ env });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getConversationPaths(feedbackDir) {
|
|
80
|
+
const resolved = resolveFeedbackDir(feedbackDir);
|
|
81
|
+
return {
|
|
82
|
+
feedbackDir: resolved,
|
|
83
|
+
conversationLogPath: path.join(resolved, 'conversation-window.jsonl'),
|
|
84
|
+
feedbackLogPath: path.join(resolved, 'feedback-log.jsonl'),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeChatHistory(entries = []) {
|
|
89
|
+
if (!Array.isArray(entries)) return [];
|
|
90
|
+
return entries
|
|
91
|
+
.map((entry) => {
|
|
92
|
+
if (typeof entry === 'string') {
|
|
93
|
+
const text = normalizeText(entry);
|
|
94
|
+
return text ? { author: null, text, timestamp: null, source: 'chat_history' } : null;
|
|
95
|
+
}
|
|
96
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
97
|
+
const text = normalizeText(entry.text || entry.body || entry.message || entry.content);
|
|
98
|
+
if (!text) return null;
|
|
99
|
+
return {
|
|
100
|
+
author: normalizeText(entry.author || entry.role || entry.user || entry.name) || null,
|
|
101
|
+
text,
|
|
102
|
+
timestamp: normalizeText(entry.timestamp || entry.createdAt || entry.updatedAt) || null,
|
|
103
|
+
source: normalizeText(entry.source) || 'chat_history',
|
|
104
|
+
};
|
|
105
|
+
})
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recordConversationEntry(entry, options = {}) {
|
|
110
|
+
const { conversationLogPath } = getConversationPaths(options.feedbackDir);
|
|
111
|
+
const normalized = normalizeChatHistory([entry])[0];
|
|
112
|
+
if (!normalized) {
|
|
113
|
+
return { recorded: false, reason: 'empty_text', conversationLogPath };
|
|
114
|
+
}
|
|
115
|
+
const record = {
|
|
116
|
+
...normalized,
|
|
117
|
+
timestamp: normalized.timestamp || new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
appendJsonl(conversationLogPath, record);
|
|
120
|
+
return { recorded: true, record, conversationLogPath };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readRecentConversationWindow(options = {}) {
|
|
124
|
+
const limit = Number(options.limit || DEFAULT_HISTORY_LIMIT);
|
|
125
|
+
const { conversationLogPath } = getConversationPaths(options.feedbackDir);
|
|
126
|
+
return readJsonlTail(conversationLogPath, limit)
|
|
127
|
+
.map((entry) => normalizeChatHistory([entry])[0])
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findFeedbackEventById(feedbackId, options = {}) {
|
|
132
|
+
if (!feedbackId) return null;
|
|
133
|
+
const { feedbackLogPath } = getConversationPaths(options.feedbackDir);
|
|
134
|
+
const matches = readJsonlTail(feedbackLogPath, Number(options.searchLimit || 200));
|
|
135
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
136
|
+
if (matches[index] && matches[index].id === feedbackId) {
|
|
137
|
+
return matches[index];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildLastActionMessage(lastAction) {
|
|
144
|
+
if (!lastAction || typeof lastAction !== 'object') return null;
|
|
145
|
+
const tool = normalizeText(lastAction.tool || lastAction.tool_name || 'unknown tool');
|
|
146
|
+
const file = normalizeText(lastAction.file || lastAction.path || '');
|
|
147
|
+
const detail = file ? `${tool} on ${file}` : tool;
|
|
148
|
+
return {
|
|
149
|
+
author: 'tool',
|
|
150
|
+
text: `Last action: ${detail}`,
|
|
151
|
+
timestamp: normalizeText(lastAction.timestamp) || null,
|
|
152
|
+
source: 'last_action',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildRelatedFeedbackMessages(feedbackEvent) {
|
|
157
|
+
if (!feedbackEvent || typeof feedbackEvent !== 'object') return [];
|
|
158
|
+
const messages = [];
|
|
159
|
+
|
|
160
|
+
if (feedbackEvent.conversationWindow && Array.isArray(feedbackEvent.conversationWindow)) {
|
|
161
|
+
for (const entry of normalizeChatHistory(feedbackEvent.conversationWindow)) {
|
|
162
|
+
messages.push({ ...entry, source: 'related_feedback_window' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const snippets = [
|
|
167
|
+
feedbackEvent.submittedContext || null,
|
|
168
|
+
feedbackEvent.context || null,
|
|
169
|
+
feedbackEvent.whatWentWrong || null,
|
|
170
|
+
feedbackEvent.whatWorked || null,
|
|
171
|
+
feedbackEvent.whatToChange || null,
|
|
172
|
+
].filter(Boolean);
|
|
173
|
+
|
|
174
|
+
for (const text of snippets) {
|
|
175
|
+
messages.push({
|
|
176
|
+
author: 'related-feedback',
|
|
177
|
+
text: normalizeText(text),
|
|
178
|
+
timestamp: normalizeText(feedbackEvent.timestamp) || null,
|
|
179
|
+
source: 'related_feedback',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const lastActionMessage = buildLastActionMessage(feedbackEvent.lastAction);
|
|
184
|
+
if (lastActionMessage) messages.push(lastActionMessage);
|
|
185
|
+
|
|
186
|
+
return messages;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function matchesAny(text, patterns) {
|
|
190
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function chooseMessage(messages, predicate) {
|
|
194
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
195
|
+
if (predicate(messages[index])) return messages[index];
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildRuleSuggestion(correctionMessage, signal) {
|
|
201
|
+
if (!correctionMessage) return null;
|
|
202
|
+
const text = truncate(correctionMessage.text, 120);
|
|
203
|
+
if (signal === 'negative' && /\bnever\b/i.test(text)) return text;
|
|
204
|
+
if (signal === 'negative' && /\bdo not\b/i.test(text)) {
|
|
205
|
+
return text.replace(/\bdo not\b/i, 'Never');
|
|
206
|
+
}
|
|
207
|
+
if (signal === 'negative' && /\bdon['’]?t\b/i.test(text)) {
|
|
208
|
+
return text
|
|
209
|
+
.replace(/\bdon['’]?t\b/i, 'Never')
|
|
210
|
+
.replace(/\bdo not\b/i, 'Never');
|
|
211
|
+
}
|
|
212
|
+
if (signal === 'negative' && /\bavoid\b/i.test(text)) {
|
|
213
|
+
return text.replace(/\bavoid\b/i, 'Avoid');
|
|
214
|
+
}
|
|
215
|
+
if (signal === 'negative') return `Follow the earlier instruction: ${text}`;
|
|
216
|
+
return `Repeat the successful pattern: ${text}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function inferNegativeDistillation(messages, params) {
|
|
220
|
+
const correctionMessage = chooseMessage(messages, (entry) => {
|
|
221
|
+
const text = normalizeText(entry.text);
|
|
222
|
+
return Boolean(text) && matchesAny(text, CORRECTION_PATTERNS);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const failureMessage = chooseMessage(messages, (entry) => {
|
|
226
|
+
const text = normalizeText(entry.text);
|
|
227
|
+
return Boolean(text) && matchesAny(text, FAILURE_PATTERNS);
|
|
228
|
+
}) || buildLastActionMessage(params.lastAction);
|
|
229
|
+
|
|
230
|
+
if (!correctionMessage && !failureMessage) {
|
|
231
|
+
return {
|
|
232
|
+
usedHistory: false,
|
|
233
|
+
inferredFields: {},
|
|
234
|
+
lessonProposal: null,
|
|
235
|
+
evidence: [],
|
|
236
|
+
source: 'none',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const evidence = [correctionMessage, failureMessage].filter(Boolean).map((entry) => truncate(entry.text));
|
|
241
|
+
const whatWentWrong = correctionMessage
|
|
242
|
+
? `It ignored a prior instruction: ${truncate(correctionMessage.text, 140)}`
|
|
243
|
+
: `The failure centered on: ${truncate(failureMessage.text, 140)}`;
|
|
244
|
+
const whatToChange = correctionMessage
|
|
245
|
+
? `Follow the earlier instruction instead of repeating the same pattern: ${truncate(correctionMessage.text, 140)}`
|
|
246
|
+
: failureMessage
|
|
247
|
+
? `Inspect and correct the failing step before repeating it: ${truncate(failureMessage.text, 140)}`
|
|
248
|
+
: null;
|
|
249
|
+
const context = failureMessage
|
|
250
|
+
? `History-aware distillation linked this failure to ${truncate(failureMessage.text, 140)}`
|
|
251
|
+
: `History-aware distillation linked this failure to ${truncate(correctionMessage.text, 140)}`;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
usedHistory: true,
|
|
255
|
+
source: correctionMessage ? correctionMessage.source : failureMessage.source,
|
|
256
|
+
inferredFields: {
|
|
257
|
+
context,
|
|
258
|
+
whatWentWrong,
|
|
259
|
+
whatToChange,
|
|
260
|
+
},
|
|
261
|
+
lessonProposal: {
|
|
262
|
+
summary: whatWentWrong,
|
|
263
|
+
proposedRule: buildRuleSuggestion(correctionMessage, 'negative'),
|
|
264
|
+
confidence: correctionMessage && failureMessage ? 0.92 : 0.78,
|
|
265
|
+
},
|
|
266
|
+
evidence,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function inferPositiveDistillation(messages) {
|
|
271
|
+
const successMessage = chooseMessage(messages, (entry) => {
|
|
272
|
+
const text = normalizeText(entry.text);
|
|
273
|
+
return Boolean(text) && matchesAny(text, SUCCESS_PATTERNS);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!successMessage) {
|
|
277
|
+
return {
|
|
278
|
+
usedHistory: false,
|
|
279
|
+
inferredFields: {},
|
|
280
|
+
lessonProposal: null,
|
|
281
|
+
evidence: [],
|
|
282
|
+
source: 'none',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const whatWorked = `The successful pattern was: ${truncate(successMessage.text, 140)}`;
|
|
287
|
+
return {
|
|
288
|
+
usedHistory: true,
|
|
289
|
+
source: successMessage.source,
|
|
290
|
+
inferredFields: {
|
|
291
|
+
context: `History-aware distillation found a successful pattern in recent conversation: ${truncate(successMessage.text, 140)}`,
|
|
292
|
+
whatWorked,
|
|
293
|
+
},
|
|
294
|
+
lessonProposal: {
|
|
295
|
+
summary: whatWorked,
|
|
296
|
+
proposedRule: buildRuleSuggestion(successMessage, 'positive'),
|
|
297
|
+
confidence: 0.81,
|
|
298
|
+
},
|
|
299
|
+
evidence: [truncate(successMessage.text)],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function distillFeedbackHistory(params = {}) {
|
|
304
|
+
const signal = String(params.signal || '').toLowerCase().trim();
|
|
305
|
+
const historyLimit = Number(params.historyLimit || DEFAULT_HISTORY_LIMIT);
|
|
306
|
+
const explicitHistory = normalizeChatHistory(params.chatHistory || params.messages || []);
|
|
307
|
+
const fallbackHistory = params.allowLocalConversationFallback
|
|
308
|
+
? readRecentConversationWindow({ feedbackDir: params.feedbackDir, limit: historyLimit })
|
|
309
|
+
: [];
|
|
310
|
+
const relatedEvent = findFeedbackEventById(params.relatedFeedbackId, {
|
|
311
|
+
feedbackDir: params.feedbackDir,
|
|
312
|
+
searchLimit: params.searchLimit,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const conversationWindow = [
|
|
316
|
+
...explicitHistory,
|
|
317
|
+
...(explicitHistory.length === 0 ? fallbackHistory : []),
|
|
318
|
+
...buildRelatedFeedbackMessages(relatedEvent),
|
|
319
|
+
]
|
|
320
|
+
.filter((entry) => entry && normalizeText(entry.text))
|
|
321
|
+
.slice(-historyLimit);
|
|
322
|
+
|
|
323
|
+
const distillation = signal === 'negative'
|
|
324
|
+
? inferNegativeDistillation(conversationWindow, params)
|
|
325
|
+
: inferPositiveDistillation(conversationWindow, params);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
usedHistory: distillation.usedHistory,
|
|
329
|
+
source: explicitHistory.length > 0
|
|
330
|
+
? 'chat_history'
|
|
331
|
+
: fallbackHistory.length > 0
|
|
332
|
+
? 'local_conversation_window'
|
|
333
|
+
: relatedEvent ? 'related_feedback' : 'none',
|
|
334
|
+
conversationWindow,
|
|
335
|
+
relatedFeedbackId: relatedEvent ? relatedEvent.id : null,
|
|
336
|
+
inferredFields: distillation.inferredFields,
|
|
337
|
+
lessonProposal: distillation.lessonProposal,
|
|
338
|
+
evidence: distillation.evidence,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function main(argv = process.argv.slice(2)) {
|
|
343
|
+
const [command, ...rest] = argv;
|
|
344
|
+
if (command !== 'record') {
|
|
345
|
+
console.error('Usage: node scripts/feedback-history-distiller.js record --author=user --text="..."');
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const args = {};
|
|
350
|
+
for (const token of rest) {
|
|
351
|
+
if (!token.startsWith('--')) continue;
|
|
352
|
+
const [key, ...valueParts] = token.slice(2).split('=');
|
|
353
|
+
args[key] = valueParts.join('=');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = recordConversationEntry({
|
|
357
|
+
author: args.author || null,
|
|
358
|
+
text: args.text || '',
|
|
359
|
+
timestamp: args.timestamp || new Date().toISOString(),
|
|
360
|
+
source: args.source || 'cli_record',
|
|
361
|
+
}, {
|
|
362
|
+
feedbackDir: args.feedbackDir,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (!result.recorded) {
|
|
366
|
+
console.error(result.reason || 'failed_to_record');
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
process.stdout.write(JSON.stringify(result, null, 2));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (require.main === module) {
|
|
374
|
+
main();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
DEFAULT_HISTORY_LIMIT,
|
|
379
|
+
distillFeedbackHistory,
|
|
380
|
+
findFeedbackEventById,
|
|
381
|
+
getConversationPaths,
|
|
382
|
+
normalizeChatHistory,
|
|
383
|
+
readRecentConversationWindow,
|
|
384
|
+
recordConversationEntry,
|
|
385
|
+
};
|