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.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +96 -122
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +54 -34
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +100 -77
  8. package/config/gates/default.json +2 -2
  9. package/config/github-about.json +2 -5
  10. package/config/mcp-allowlists.json +5 -0
  11. package/config/post-deploy-marketing-pages.json +1 -1
  12. package/hooks/hooks.json +38 -0
  13. package/package.json +19 -8
  14. package/public/chatgpt-app.html +2 -2
  15. package/public/codex-enterprise.html +1 -1
  16. package/public/codex-plugin.html +1 -1
  17. package/public/diagnostic.html +23 -1
  18. package/public/guide.html +1 -1
  19. package/public/index.html +172 -201
  20. package/public/numbers.html +2 -2
  21. package/public/partner-intake.html +198 -0
  22. package/public/pricing.html +51 -14
  23. package/scripts/auto-wire-hooks.js +58 -2
  24. package/scripts/billing.js +815 -137
  25. package/scripts/checkout-attribution-reference.js +85 -0
  26. package/scripts/claude-feedback-sync.js +23 -6
  27. package/scripts/cli-feedback.js +20 -10
  28. package/scripts/feedback-history-distiller.js +385 -0
  29. package/scripts/feedback-loop.js +290 -1
  30. package/scripts/feedback-quality.js +25 -26
  31. package/scripts/feedback-sanitizer.js +151 -3
  32. package/scripts/gates-engine.js +128 -49
  33. package/scripts/hosted-config.js +9 -2
  34. package/scripts/mailer/resend-mailer.js +11 -5
  35. package/scripts/secret-scanner.js +363 -68
  36. package/scripts/self-protection.js +21 -36
  37. package/src/api/server.js +121 -12
  38. 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 normalized = String(text || '').toLowerCase();
130
- if (/(thumbs?\s*down|that failed|that was wrong|fix this)/i.test(normalized)) return 'down';
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 && captureResult.feedbackEvent) {
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);
@@ -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 feedbackSignal = result.feedbackResult?.signal || result.feedbackResult?.feedbackEvent?.signal;
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 (result.feedbackResult && result.feedbackResult.accepted !== false) {
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 = (result.feedbackResult.feedbackEvent && result.feedbackResult.feedbackEvent.id) || result.feedbackResult.id;
100
- const memoryId = (result.feedbackResult.memoryRecord && result.feedbackResult.memoryRecord.id) || result.feedbackResult.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
- process.stderr.write(`✅ Feedback captured (${feedbackId}${memoryId ? `, ${memoryId}` : ''})\n`);
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: ${(result.feedbackResult && result.feedbackResult.reason) || 'unknown'}${RST}`);
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 && result.forceGateHint.message) {
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
+ };