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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +37 -2
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +30 -7
- package/config/mcp-allowlists.json +5 -0
- package/package.json +2 -1
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/auto-wire-hooks.js +58 -2
- package/scripts/cli-feedback.js +1 -0
- package/scripts/feedback-history-distiller.js +385 -0
|
@@ -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.
|
|
4
|
+
"version": "1.28.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Igor Ganapolsky",
|
|
7
7
|
"email": "ig5973700@gmail.com",
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"thumbgate": {
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["--yes", "--package", "thumbgate@1.28.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
},
|
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
|
|
2817
|
-
|
|
2818
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1779
|
+
<span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.28.2</span>
|
|
1780
1780
|
</div>
|
|
1781
1781
|
</footer>
|
|
1782
1782
|
|
package/public/numbers.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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,
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -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
|
+
};
|