thumbgate 1.28.1 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thumbgate",
3
3
  "description": "One 👎 becomes a hard rule the agent cannot bypass. Captures thumbs-down feedback, distills it into PreToolUse Pre-Action Checks, enforced across every future Claude Code session.",
4
- "version": "1.28.1",
4
+ "version": "1.28.2",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky",
7
7
  "email": "ig5973700@gmail.com",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.28.1",
3
+ "version": "1.28.2",
4
4
  "description": "ThumbGate — 👍👎 feedback that teaches your AI agent. Thumbs down a mistake, it never happens again.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "transport": "stdio",
@@ -2,13 +2,13 @@
2
2
  "mcpServers": {
3
3
  "thumbgate": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "thumbgate@1.28.1", "thumbgate", "serve"]
5
+ "args": ["--yes", "--package", "thumbgate@1.28.2", "thumbgate", "serve"]
6
6
  }
7
7
  },
8
8
  "hooks": {
9
9
  "preToolUse": {
10
10
  "command": "npx",
11
- "args": ["--yes", "--package", "thumbgate@1.28.1", "thumbgate", "gate-check"]
11
+ "args": ["--yes", "--package", "thumbgate@1.28.2", "thumbgate", "gate-check"]
12
12
  }
13
13
  }
14
14
  }
@@ -187,6 +187,24 @@ const PRIVATE_MCP_MODULES = Object.freeze({
187
187
  lessonSearch: path.resolve(__dirname, '../../scripts/lesson-search.js'),
188
188
  });
189
189
 
190
+ const PRIVATE_MCP_TOOL_REQUIREMENTS = Object.freeze({
191
+ search_lessons: ['lessonSearch'],
192
+ reflect_on_feedback: ['reflectorAgent'],
193
+ list_intents: ['intentRouter'],
194
+ plan_intent: ['intentRouter'],
195
+ start_handoff: ['intentRouter', 'delegationRuntime'],
196
+ complete_handoff: ['delegationRuntime'],
197
+ distribute_context_to_agents: ['swarmCoordinator'],
198
+ session_report: ['sessionReport'],
199
+ generate_operator_artifact: ['operatorArtifacts'],
200
+ org_dashboard: ['orgDashboard'],
201
+ get_business_metrics: ['semanticLayer'],
202
+ describe_semantic_entity: ['semanticLayer'],
203
+ run_managed_lesson_agent: ['managedLessonAgent'],
204
+ managed_agent_status: ['managedLessonAgent'],
205
+ context_stuff_lessons: ['lessonInference'],
206
+ });
207
+
190
208
  function loadPrivateMcpModule(key) {
191
209
  const modulePath = PRIVATE_MCP_MODULES[key];
192
210
  if (!modulePath) {
@@ -204,6 +222,20 @@ function loadPrivateMcpModule(key) {
204
222
  }
205
223
  }
206
224
 
225
+ function isToolAvailable(toolName) {
226
+ const requirements = PRIVATE_MCP_TOOL_REQUIREMENTS[toolName] || [];
227
+ try {
228
+ return requirements.every((key) => Boolean(loadPrivateMcpModule(key)));
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ function listAvailableTools(profileName = getActiveMcpProfile()) {
235
+ const allowedTools = new Set(getAllowedTools(profileName));
236
+ return TOOLS.filter((tool) => allowedTools.has(tool.name) && isToolAvailable(tool.name));
237
+ }
238
+
207
239
  function unavailablePrivateMcpFeature(toolName) {
208
240
  return toTextResult({
209
241
  ok: false,
@@ -231,7 +263,7 @@ const {
231
263
  finalizeSession: finalizeFeedbackSession,
232
264
  } = require('../../scripts/feedback-session');
233
265
 
234
- const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.28.1' };
266
+ const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.28.2' };
235
267
  const COMMERCE_CATEGORIES = [
236
268
  'product_recommendation',
237
269
  'brand_compliance',
@@ -1357,7 +1389,7 @@ async function handleRequest(message) {
1357
1389
  };
1358
1390
  }
1359
1391
  if (message.method === 'ping') return {};
1360
- if (message.method === 'tools/list') return { tools: TOOLS };
1392
+ if (message.method === 'tools/list') return { tools: listAvailableTools() };
1361
1393
  if (message.method === 'tools/call') return callTool(message.params.name, message.params.arguments);
1362
1394
  throw new Error(`Unsupported method: ${message.method}`);
1363
1395
  }
@@ -1559,7 +1591,10 @@ module.exports = {
1559
1591
  buildSuggestFixResponse,
1560
1592
  __test__: {
1561
1593
  PRIVATE_MCP_MODULES,
1594
+ PRIVATE_MCP_TOOL_REQUIREMENTS,
1562
1595
  loadPrivateMcpModule,
1596
+ isToolAvailable,
1597
+ listAvailableTools,
1563
1598
  unavailablePrivateMcpFeature,
1564
1599
  callToolInner,
1565
1600
  },
@@ -7,7 +7,7 @@
7
7
  "npx",
8
8
  "--yes",
9
9
  "--package",
10
- "thumbgate@1.28.1",
10
+ "thumbgate@1.28.2",
11
11
  "thumbgate",
12
12
  "serve"
13
13
  ],
package/bin/cli.js CHANGED
@@ -444,6 +444,11 @@ function codexPreToolHookSectionBlock() {
444
444
  return `[hooks.pre_tool_use]\ncommand = "${entry.command}"\nargs = ${formatTomlStringArray(entry.args)}\n`;
445
445
  }
446
446
 
447
+ function codexUserPromptHookSectionBlock() {
448
+ const entry = canonicalCodexCliEntry(['hook-auto-capture']);
449
+ return `[hooks.user_prompt_submit]\ncommand = "${entry.command}"\nargs = ${formatTomlStringArray(entry.args)}\n`;
450
+ }
451
+
447
452
  function mcpSectionRegex(name) {
448
453
  return new RegExp(
449
454
  `^\\[mcp_servers\\.${escapeRegExp(name)}\\]\\n(?:^(?!\\[).*(?:\\n|$))*`,
@@ -461,6 +466,7 @@ function tomlSectionRegex(name) {
461
466
  function upsertCodexServerConfig(content) {
462
467
  const canonicalBlock = codexMcpSectionBlock(MCP_SERVER_NAME);
463
468
  const canonicalHookBlock = codexPreToolHookSectionBlock();
469
+ const canonicalUserPromptHookBlock = codexUserPromptHookSectionBlock();
464
470
  const sections = MCP_SERVER_NAMES.map((name) => ({
465
471
  name,
466
472
  regex: mcpSectionRegex(name),
@@ -473,7 +479,7 @@ function upsertCodexServerConfig(content) {
473
479
  const prefix = content.trimEnd();
474
480
  return {
475
481
  changed: true,
476
- content: `${prefix}${prefix ? '\n\n' : ''}${canonicalBlock}\n${canonicalHookBlock}`,
482
+ content: `${prefix}${prefix ? '\n\n' : ''}${canonicalBlock}\n${canonicalHookBlock}\n${canonicalUserPromptHookBlock}`,
477
483
  };
478
484
  }
479
485
 
@@ -517,6 +523,19 @@ function upsertCodexServerConfig(content) {
517
523
  changed = true;
518
524
  }
519
525
 
526
+ const userPromptHookRegex = tomlSectionRegex('hooks.user_prompt_submit');
527
+ if (userPromptHookRegex.test(nextContent)) {
528
+ const current = nextContent.match(userPromptHookRegex)[0];
529
+ if (current !== canonicalUserPromptHookBlock) {
530
+ nextContent = nextContent.replace(userPromptHookRegex, canonicalUserPromptHookBlock);
531
+ changed = true;
532
+ }
533
+ } else {
534
+ const prefix = nextContent.trimEnd();
535
+ nextContent = `${prefix}${prefix ? '\n\n' : ''}${canonicalUserPromptHookBlock}`;
536
+ changed = true;
537
+ }
538
+
520
539
  return {
521
540
  changed,
522
541
  content: nextContent.endsWith('\n') ? nextContent : `${nextContent}\n`,
@@ -2767,7 +2786,7 @@ function hookAutoCapture() {
2767
2786
  || promptEnvelope.prompt;
2768
2787
  const { evaluatePromptGuard } = require(path.join(PKG_ROOT, 'scripts', 'prompt-guard'));
2769
2788
  const { processInlineFeedback, formatCliOutput } = require(path.join(PKG_ROOT, 'scripts', 'cli-feedback'));
2770
- const { detectFeedbackSignal } = require(path.join(PKG_ROOT, 'scripts', 'feedback-quality'));
2789
+ const { detectFeedbackSignal, isGenericFeedbackText } = require(path.join(PKG_ROOT, 'scripts', 'feedback-quality'));
2771
2790
  const { loadOptionalModule } = require(path.join(PKG_ROOT, 'scripts', 'private-core-boundary'));
2772
2791
  const { recordConversationEntry, readRecentConversationWindow } = loadOptionalModule(
2773
2792
  path.join(PKG_ROOT, 'scripts', 'feedback-history-distiller'),
@@ -2800,6 +2819,12 @@ function hookAutoCapture() {
2800
2819
 
2801
2820
  const signal = detected.signal;
2802
2821
  const conversationWindow = readRecentConversationWindow({ limit: 8 });
2822
+ const chatHistory = conversationWindow.map((entry) => ({
2823
+ role: entry.author === 'assistant' ? 'assistant' : 'user',
2824
+ content: entry.text || '',
2825
+ }));
2826
+ const normalizedSignal = signal === 'down' ? 'negative' : 'positive';
2827
+ const genericFeedback = isGenericFeedbackText(prompt, normalizedSignal);
2803
2828
  const { buildFeedbackSourceIdentity } = require(path.join(PKG_ROOT, 'scripts', 'feedback-loop'));
2804
2829
  const sourceEvent = buildFeedbackSourceIdentity({
2805
2830
  signal,
@@ -2813,11 +2838,9 @@ function hookAutoCapture() {
2813
2838
  const result = processInlineFeedback({
2814
2839
  signal,
2815
2840
  context: prompt,
2816
- chatHistory: signal === 'down'
2817
- ? conversationWindow.map((entry) => ({ role: entry.author === 'assistant' ? 'assistant' : 'user', content: entry.text || '' }))
2818
- : undefined,
2819
- whatWentWrong: signal === 'down' ? prompt : undefined,
2820
- whatWorked: signal === 'up' ? prompt : undefined,
2841
+ chatHistory,
2842
+ whatWentWrong: signal === 'down' && !genericFeedback ? prompt : undefined,
2843
+ whatWorked: signal === 'up' && !genericFeedback ? prompt : undefined,
2821
2844
  sourceEvent,
2822
2845
  });
2823
2846
  if (!result.feedbackResult || !result.feedbackResult.duplicate) {
@@ -68,6 +68,11 @@
68
68
  "distribute_context_to_agents",
69
69
  "session_report",
70
70
  "generate_operator_artifact",
71
+ "run_managed_lesson_agent",
72
+ "managed_agent_status",
73
+ "run_self_distill",
74
+ "self_distill_status",
75
+ "context_stuff_lessons",
71
76
  "perplexity_search",
72
77
  "perplexity_ask",
73
78
  "perplexity_research",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "1.28.1",
3
+ "version": "1.28.2",
4
4
  "description": "ThumbGate Pre-Action Checks derive rules from repeated failures, flag risky tool calls, hard-block detected secret leaks, and block matches in strict mode.",
5
5
  "homepage": "https://thumbgate.ai",
6
6
  "repository": {
@@ -90,6 +90,7 @@
90
90
  "scripts/export-hf-dataset.js",
91
91
  "scripts/failure-diagnostics.js",
92
92
  "scripts/feedback-aggregate.js",
93
+ "scripts/feedback-history-distiller.js",
93
94
  "scripts/feedback-attribution.js",
94
95
  "scripts/feedback-loop.js",
95
96
  "scripts/feedback-paths.js",
package/public/index.html CHANGED
@@ -20,7 +20,7 @@ __GOOGLE_SITE_VERIFICATION_META__
20
20
  <meta property="og:image" content="https://thumbgate.ai/og.png">
21
21
  <meta name="twitter:card" content="summary_large_image">
22
22
  <meta name="twitter:image" content="https://thumbgate.ai/og.png">
23
- <meta name="thumbgate-version" content="1.28.1">
23
+ <meta name="thumbgate-version" content="1.28.2">
24
24
  <meta name="keywords" content="ThumbGate, thumbgate, AI agent orchestration, AI experience orchestration, agentic development cycle, AC/DC framework, Guide Generate Verify Solve, agent enforcement layer, pre-action checks, agent governance, Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode, workflow hardening, context engineering, AI authenticity, brand authenticity AI">
25
25
  <link rel="canonical" href="__APP_ORIGIN__/">
26
26
  <link rel="alternate" type="text/markdown" title="ThumbGate LLM context" href="__APP_ORIGIN__/llm-context.md">
@@ -1776,7 +1776,7 @@ __GA_BOOTSTRAP__
1776
1776
  <a href="https://www.linkedin.com/in/igorganapolsky" target="_blank" rel="noopener">LinkedIn</a>
1777
1777
  <a href="/blog">Blog</a>
1778
1778
  </div>
1779
- <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.28.1</span>
1779
+ <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.28.2</span>
1780
1780
  </div>
1781
1781
  </footer>
1782
1782
 
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.28.1",
28
+ "softwareVersion": "1.28.2",
29
29
  "url": "https://thumbgate.ai/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.28.1</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.28.2</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -452,6 +452,46 @@ function codexConfigPath() {
452
452
  return path.join(getHome(), '.codex', 'config.json');
453
453
  }
454
454
 
455
+ function codexTomlConfigPath(configPath = codexConfigPath()) {
456
+ return path.join(path.dirname(configPath), 'config.toml');
457
+ }
458
+
459
+ function tomlSectionRegex(name) {
460
+ return new RegExp(`^\\[${String(name).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]\\n(?:^(?!\\[).*(?:\\n|$))*`, 'm');
461
+ }
462
+
463
+ function codexUserPromptTomlBlock() {
464
+ const hookCommand = codexUserPromptHookCommand();
465
+ return `[hooks.user_prompt_submit]\ncommand = "sh"\nargs = ["-lc", ${JSON.stringify(hookCommand)}]\n`;
466
+ }
467
+
468
+ function upsertCodexUserPromptToml(configPath, dryRun = false) {
469
+ const tomlPath = codexTomlConfigPath(configPath);
470
+ const current = fs.existsSync(tomlPath) ? fs.readFileSync(tomlPath, 'utf8') : '';
471
+ const canonicalBlock = codexUserPromptTomlBlock();
472
+ const sectionRegex = tomlSectionRegex('hooks.user_prompt_submit');
473
+ let next = current;
474
+
475
+ if (sectionRegex.test(current)) {
476
+ const existingBlock = current.match(sectionRegex)[0];
477
+ if (existingBlock === canonicalBlock) {
478
+ return { changed: false, settingsPath: tomlPath };
479
+ }
480
+ next = current.replace(sectionRegex, canonicalBlock);
481
+ } else {
482
+ const prefix = current.trimEnd();
483
+ next = `${prefix}${prefix ? '\n\n' : ''}${canonicalBlock}`;
484
+ }
485
+
486
+ if (!dryRun) {
487
+ const dir = path.dirname(tomlPath);
488
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
489
+ fs.writeFileSync(tomlPath, next.endsWith('\n') ? next : `${next}\n`);
490
+ }
491
+
492
+ return { changed: true, settingsPath: tomlPath };
493
+ }
494
+
455
495
  function writeJsonFile(filePath, payload, dryRun) {
456
496
  if (dryRun) {
457
497
  return;
@@ -500,6 +540,7 @@ function wireCodexHooks(options) {
500
540
  const configPath = options.settingsPath || codexConfigPath();
501
541
  const dryRun = options.dryRun || false;
502
542
  const desiredStatusLine = codexStatuslineCommand();
543
+ const tomlResult = upsertCodexUserPromptToml(configPath, dryRun);
503
544
 
504
545
  let config = loadJsonFile(configPath) || {};
505
546
  config.hooks = config.hooks || {};
@@ -519,15 +560,28 @@ function wireCodexHooks(options) {
519
560
  if (added.length === 0) {
520
561
  if (syncCodexStatusLine(config, desiredStatusLine)) {
521
562
  writeJsonFile(configPath, config, dryRun);
522
- return { changed: true, settingsPath: configPath, added: [{ lifecycle: 'statusLine', command: desiredStatusLine }] };
563
+ const statusAdded = [{ lifecycle: 'statusLine', command: desiredStatusLine }];
564
+ if (tomlResult.changed) {
565
+ statusAdded.push({ lifecycle: 'UserPromptSubmit', command: `${codexUserPromptHookCommand()} (${tomlResult.settingsPath})` });
566
+ }
567
+ return { changed: true, settingsPath: configPath, added: statusAdded };
523
568
  }
524
- return { changed: false, settingsPath: configPath, added: [] };
569
+ return {
570
+ changed: tomlResult.changed,
571
+ settingsPath: configPath,
572
+ added: tomlResult.changed
573
+ ? [{ lifecycle: 'UserPromptSubmit', command: `${codexUserPromptHookCommand()} (${tomlResult.settingsPath})` }]
574
+ : [],
575
+ };
525
576
  }
526
577
 
527
578
  syncCodexStatusLine(config, desiredStatusLine);
528
579
  writeJsonFile(configPath, config, dryRun);
529
580
 
530
581
  added.push({ lifecycle: 'statusLine', command: desiredStatusLine });
582
+ if (tomlResult.changed) {
583
+ added.push({ lifecycle: 'UserPromptSubmit', command: `${codexUserPromptHookCommand()} (${tomlResult.settingsPath})` });
584
+ }
531
585
  return { changed: true, settingsPath: configPath, added };
532
586
  }
533
587
 
@@ -702,6 +756,8 @@ module.exports = {
702
756
  claudeSharedSettingsPath,
703
757
  claudeProjectSettingsPath,
704
758
  codexConfigPath,
759
+ codexTomlConfigPath,
760
+ upsertCodexUserPromptToml,
705
761
  geminiSettingsPath,
706
762
  syncClaudeStatusLine,
707
763
  forgeConfigPath,
@@ -58,6 +58,7 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
58
58
  context: context || (isDown ? 'Thumbs down from CLI' : 'Thumbs up from CLI'),
59
59
  whatWentWrong: whatWentWrong || undefined,
60
60
  whatWorked: whatWorked || undefined,
61
+ chatHistory,
61
62
  sourceEvent,
62
63
  });
63
64
  } catch (err) {
@@ -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
+ };