thumbgate 1.28.1 → 1.28.3

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.
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Cross-Encoder Reranker for ThumbGate lesson retrieval.
6
+ *
7
+ * Two-stage retrieval:
8
+ * Stage 1: Fast candidate retrieval (existing bigram Jaccard + keyword matching)
9
+ * Stage 2: Cross-encoder reranking scores query-document pairs jointly
10
+ *
11
+ * The cross-encoder evaluates the query AND each lesson together (not independently),
12
+ * catching false positives that keyword/vector search misses.
13
+ *
14
+ * Architecture reference: "Advanced RAG Retrieval: Cross-Encoders & Reranking"
15
+ * (Towards Data Science, April 2026)
16
+ *
17
+ * When LLM is available (ANTHROPIC_API_KEY), uses Claude as the cross-encoder.
18
+ * Falls back to enhanced heuristic scoring when LLM is unavailable.
19
+ */
20
+
21
+ const { retrieveRelevantLessons, scoreRelevance, buildActionSignature } = require('./lesson-retrieval');
22
+
23
+ /**
24
+ * Heuristic cross-encoder: scores a (query, document) pair jointly.
25
+ * Unlike bi-encoder (independent embeddings), this examines the pair together
26
+ * to find semantic relationships that keyword matching misses.
27
+ */
28
+ function heuristicCrossEncode(query, document) {
29
+ const queryLower = (query || '').toLowerCase();
30
+ const docLower = (document || '').toLowerCase();
31
+
32
+ let score = 0;
33
+
34
+ // 1. Exact substring containment (strongest signal)
35
+ if (queryLower.length > 3 && docLower.length > 3 &&
36
+ (docLower.includes(queryLower) || queryLower.includes(docLower))) {
37
+ score += 0.9;
38
+ return Math.min(score, 1);
39
+ }
40
+
41
+ // 2. Shared noun phrases (not just tokens — consecutive word pairs)
42
+ const queryPhrases = extractPhrases(queryLower);
43
+ const docPhrases = extractPhrases(docLower);
44
+ const phraseOverlap = queryPhrases.filter((p) => docPhrases.includes(p));
45
+ score += Math.min(phraseOverlap.length * 0.15, 0.5);
46
+
47
+ // 3. Semantic category matching
48
+ const categories = {
49
+ destructive: ['delete', 'remove', 'drop', 'destroy', 'wipe', 'truncate', 'rm -rf', 'force-push', 'reset --hard'],
50
+ git: ['git', 'push', 'pull', 'merge', 'rebase', 'branch', 'commit', 'checkout', 'stash'],
51
+ database: ['sql', 'query', 'table', 'migration', 'schema', 'database', 'insert', 'update', 'select'],
52
+ deploy: ['deploy', 'release', 'publish', 'railway', 'vercel', 'heroku', 'npm publish'],
53
+ security: ['secret', 'token', 'api key', 'password', 'credential', 'env', '.env', 'pem'],
54
+ file: ['edit', 'write', 'create', 'modify', 'config', 'package.json', 'readme'],
55
+ };
56
+
57
+ for (const [, terms] of Object.entries(categories)) {
58
+ const queryHit = terms.some((t) => queryLower.includes(t));
59
+ const docHit = terms.some((t) => docLower.includes(t));
60
+ if (queryHit && docHit) {
61
+ score += 0.25;
62
+ break; // Only count strongest category match
63
+ }
64
+ }
65
+
66
+ // 4. Action-target alignment (e.g., "git push" in query matches "push to main" in doc)
67
+ const queryVerbs = extractVerbs(queryLower);
68
+ const docVerbs = extractVerbs(docLower);
69
+ const verbOverlap = queryVerbs.filter((v) => docVerbs.includes(v));
70
+ score += Math.min(verbOverlap.length * 0.1, 0.3);
71
+
72
+ // 5. Negation alignment (both about what NOT to do)
73
+ const queryNegated = /\b(don'?t|never|avoid|block|prevent|stop)\b/.test(queryLower);
74
+ const docNegated = /\b(don'?t|never|avoid|block|prevent|stop)\b/.test(docLower);
75
+ if (queryNegated && docNegated) score += 0.1;
76
+
77
+ return Math.min(score, 1);
78
+ }
79
+
80
+ /**
81
+ * LLM cross-encoder: uses Claude to score relevance of query-document pairs.
82
+ * More accurate but requires API key and costs tokens.
83
+ */
84
+ async function llmCrossEncode(query, documents) {
85
+ const { isAvailable, callClaudeJson, MODELS } = require('./llm-client');
86
+ if (!isAvailable()) return null;
87
+
88
+ const docList = documents
89
+ .map((d, i) => `[${i}] ${(d.title || '').slice(0, 100)} | ${(d.content || '').slice(0, 200)}`)
90
+ .join('\n');
91
+
92
+ const prompt = `You are a relevance scoring engine. Given a query and a list of documents, score each document's relevance to the query from 0.0 (irrelevant) to 1.0 (highly relevant).
93
+
94
+ Query: "${query.slice(0, 300)}"
95
+
96
+ Documents:
97
+ ${docList}
98
+
99
+ Return ONLY a JSON array of scores, one per document. Example: [0.9, 0.2, 0.7, 0.1, 0.5]
100
+ No other text.`;
101
+
102
+ try {
103
+ const scores = await callClaudeJson({
104
+ systemPrompt: 'You are a relevance scoring engine. Return only JSON arrays of numbers.',
105
+ userPrompt: prompt,
106
+ model: MODELS.FAST,
107
+ maxTokens: 256,
108
+ cache: true,
109
+ });
110
+ if (Array.isArray(scores) && scores.length === documents.length) {
111
+ return scores.map((s) => Math.max(0, Math.min(1, Number(s) || 0)));
112
+ }
113
+ } catch { /* fall back to heuristic */ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Two-stage retrieval with cross-encoder reranking.
119
+ *
120
+ * Stage 1: Retrieve top N candidates using existing keyword + bigram matching
121
+ * Stage 2: Rerank candidates using cross-encoder (LLM or heuristic)
122
+ * Return top K results by cross-encoder score
123
+ */
124
+ async function retrieveWithReranking(toolName, actionContext, options = {}) {
125
+ const {
126
+ candidateCount = 20,
127
+ maxResults = 5,
128
+ useLLM = false,
129
+ feedbackDir,
130
+ } = options;
131
+
132
+ // Stage 1: Fast candidate retrieval (existing system)
133
+ const candidates = retrieveRelevantLessons(toolName, actionContext, {
134
+ maxResults: candidateCount,
135
+ feedbackDir,
136
+ });
137
+
138
+ if (candidates.length === 0) return [];
139
+ if (candidates.length <= maxResults) return candidates;
140
+
141
+ const query = `${toolName || ''} ${actionContext || ''}`.trim();
142
+
143
+ // Stage 2: Cross-encoder reranking
144
+ let rerankedScores;
145
+
146
+ if (useLLM) {
147
+ rerankedScores = await llmCrossEncode(query, candidates);
148
+ }
149
+
150
+ // Fall back to heuristic cross-encoder if LLM unavailable or failed
151
+ if (!rerankedScores) {
152
+ rerankedScores = candidates.map((c) => {
153
+ const docText = `${c.title || ''} ${c.content || ''}`;
154
+ return heuristicCrossEncode(query, docText);
155
+ });
156
+ }
157
+
158
+ // Combine original relevance score with cross-encoder score
159
+ // Weight: 40% original, 60% cross-encoder (cross-encoder is more precise)
160
+ const reranked = candidates.map((c, i) => ({
161
+ ...c,
162
+ crossEncoderScore: rerankedScores[i],
163
+ combinedScore: c.relevanceScore * 0.4 + rerankedScores[i] * 0.6,
164
+ }));
165
+
166
+ return reranked
167
+ .sort((a, b) => b.combinedScore - a.combinedScore)
168
+ .slice(0, maxResults);
169
+ }
170
+
171
+ /**
172
+ * Synchronous version for use in PreToolUse hooks (cannot be async).
173
+ */
174
+ function retrieveWithRerankingSync(toolName, actionContext, options = {}) {
175
+ const {
176
+ candidateCount = 20,
177
+ maxResults = 5,
178
+ feedbackDir,
179
+ } = options;
180
+
181
+ const candidates = retrieveRelevantLessons(toolName, actionContext, {
182
+ maxResults: candidateCount,
183
+ feedbackDir,
184
+ });
185
+
186
+ if (candidates.length === 0) return [];
187
+ if (candidates.length <= maxResults) return candidates;
188
+
189
+ const query = `${toolName || ''} ${actionContext || ''}`.trim();
190
+
191
+ const rerankedScores = candidates.map((c) => {
192
+ const docText = `${c.title || ''} ${c.content || ''}`;
193
+ return heuristicCrossEncode(query, docText);
194
+ });
195
+
196
+ const reranked = candidates.map((c, i) => ({
197
+ ...c,
198
+ crossEncoderScore: rerankedScores[i],
199
+ combinedScore: c.relevanceScore * 0.4 + rerankedScores[i] * 0.6,
200
+ }));
201
+
202
+ return reranked
203
+ .sort((a, b) => b.combinedScore - a.combinedScore)
204
+ .slice(0, maxResults);
205
+ }
206
+
207
+ // --- Utility functions ---
208
+
209
+ function extractPhrases(text) {
210
+ const words = text.split(/\s+/).filter((w) => w.length > 2);
211
+ const phrases = [];
212
+ for (let i = 0; i < words.length - 1; i++) {
213
+ phrases.push(`${words[i]} ${words[i + 1]}`);
214
+ }
215
+ return phrases;
216
+ }
217
+
218
+ function extractVerbs(text) {
219
+ const verbPatterns = [
220
+ 'push', 'pull', 'merge', 'delete', 'create', 'edit', 'write', 'read',
221
+ 'deploy', 'install', 'remove', 'run', 'execute', 'build', 'test',
222
+ 'commit', 'rebase', 'reset', 'drop', 'truncate', 'migrate', 'publish',
223
+ 'block', 'allow', 'approve', 'deny', 'warn', 'log',
224
+ ];
225
+ return verbPatterns.filter((v) => text.includes(v));
226
+ }
227
+
228
+ module.exports = {
229
+ heuristicCrossEncode,
230
+ llmCrossEncode,
231
+ retrieveWithReranking,
232
+ retrieveWithRerankingSync,
233
+ extractPhrases,
234
+ extractVerbs,
235
+ };
@@ -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
+ };